diff --git a/apps/login/app/(login)/accounts/page.tsx b/apps/login/app/(login)/accounts/page.tsx index ed8e68d523d..b017dbc8709 100644 --- a/apps/login/app/(login)/accounts/page.tsx +++ b/apps/login/app/(login)/accounts/page.tsx @@ -22,6 +22,7 @@ async function loadSessions(): Promise { export default async function Page() { let sessions = await loadSessions(); + return (

Accounts

@@ -29,7 +30,7 @@ export default async function Page() {
- +
diff --git a/apps/login/app/(login)/loginname/page.tsx b/apps/login/app/(login)/loginname/page.tsx new file mode 100644 index 00000000000..735ce1854ff --- /dev/null +++ b/apps/login/app/(login)/loginname/page.tsx @@ -0,0 +1,26 @@ +import { getLoginSettings, server } from "#/lib/zitadel"; +import UsernameForm from "#/ui/UsernameForm"; + +export default async function Page({ + searchParams, +}: { + searchParams: Record; +}) { + const loginName = searchParams?.loginName; + const submit: boolean = searchParams?.submit === "true"; + + const loginSettings = await getLoginSettings(server); + + return ( +
+

Welcome back!

+

Enter your login data.

+ + +
+ ); +} diff --git a/apps/login/app/(login)/passkey/page.tsx b/apps/login/app/(login)/passkey/login/page.tsx similarity index 62% rename from apps/login/app/(login)/passkey/page.tsx rename to apps/login/app/(login)/passkey/login/page.tsx index 7f867b784d4..d8eac1ef4f4 100644 --- a/apps/login/app/(login)/passkey/page.tsx +++ b/apps/login/app/(login)/passkey/login/page.tsx @@ -1,19 +1,24 @@ import { getSession, server } from "#/lib/zitadel"; import Alert from "#/ui/Alert"; +import LoginPasskey from "#/ui/LoginPasskey"; import UserAvatar from "#/ui/UserAvatar"; import { getMostRecentCookieWithLoginname } from "#/utils/cookies"; +const title = "Authenticate with a passkey"; +const description = + "Your device will ask for your fingerprint, face, or screen lock"; + export default async function Page({ searchParams, }: { searchParams: Record; }) { - const { loginName } = searchParams; + const { loginName, altPassword } = searchParams; + const sessionFactors = await loadSession(loginName); async function loadSession(loginName?: string) { const recent = await getMostRecentCookieWithLoginname(loginName); - return getSession(server, recent.id, recent.token).then((response) => { if (response?.session) { return response.session; @@ -23,16 +28,7 @@ export default async function Page({ return (
-

Login with Passkey

-

Authenticate with your passkey device

- {!sessionFactors && ( -
- - Could not get the context of the user. Make sure to enter the - username first or provide a loginName as searchParam. - -
- )} +

{title}

{sessionFactors && ( )} +

{description}

+ + {!sessionFactors &&
} + + {!loginName && ( + Provide your active session as loginName param + )} + + {loginName && ( + + )}
); } diff --git a/apps/login/app/(login)/password/page.tsx b/apps/login/app/(login)/password/page.tsx index dbd0bcdc42f..0ea96aa6856 100644 --- a/apps/login/app/(login)/password/page.tsx +++ b/apps/login/app/(login)/password/page.tsx @@ -9,7 +9,7 @@ export default async function Page({ }: { searchParams: Record; }) { - const { loginName } = searchParams; + const { loginName, promptPasswordless, alt } = searchParams; const sessionFactors = await loadSession(loginName); async function loadSession(loginName?: string) { @@ -44,7 +44,11 @@ export default async function Page({ > )} - +
); } diff --git a/apps/login/app/(login)/username/page.tsx b/apps/login/app/(login)/username/page.tsx deleted file mode 100644 index 069fb0160b8..00000000000 --- a/apps/login/app/(login)/username/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import UsernameForm from "#/ui/UsernameForm"; - -export default function Page() { - return ( -
-

Welcome back!

-

Enter your login data.

- - -
- ); -} diff --git a/apps/login/app/api/loginname/route.ts b/apps/login/app/api/loginname/route.ts new file mode 100644 index 00000000000..1fa251e70d5 --- /dev/null +++ b/apps/login/app/api/loginname/route.ts @@ -0,0 +1,79 @@ +import { + getSession, + listAuthenticationMethodTypes, + server, +} from "#/lib/zitadel"; +import { getSessionCookieById } from "#/utils/cookies"; +import { createSessionAndUpdateCookie } from "#/utils/session"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const sessionId = searchParams.get("sessionId"); + if (sessionId) { + const sessionCookie = await getSessionCookieById(sessionId); + + const session = await getSession( + server, + sessionCookie.id, + sessionCookie.token + ); + + const userId = session?.session?.factors?.user?.id; + + if (userId) { + return listAuthenticationMethodTypes(userId) + .then((methods) => { + return NextResponse.json(methods); + }) + .catch((error) => { + return NextResponse.json(error, { status: 500 }); + }); + } else { + return NextResponse.json( + { details: "could not get session" }, + { status: 500 } + ); + } + } else { + return NextResponse.json({}, { status: 400 }); + } +} + +export async function POST(request: NextRequest) { + const body = await request.json(); + if (body) { + const { loginName } = body; + + const domain: string = request.nextUrl.hostname; + + return createSessionAndUpdateCookie(loginName, undefined, domain, undefined) + .then((session) => { + if (session.factors?.user?.id) { + return listAuthenticationMethodTypes(session.factors?.user?.id) + .then((methods) => { + return NextResponse.json({ + authMethodTypes: methods.authMethodTypes, + sessionId: session.id, + factors: session.factors, + }); + }) + .catch((error) => { + return NextResponse.json(error, { status: 500 }); + }); + } else { + throw "No user id found in session"; + } + }) + .catch((error) => { + return NextResponse.json( + { + details: "could not add session to cookie", + }, + { status: 500 } + ); + }); + } else { + return NextResponse.error(); + } +} diff --git a/apps/login/app/passkeys/route.ts b/apps/login/app/api/passkeys/route.ts similarity index 100% rename from apps/login/app/passkeys/route.ts rename to apps/login/app/api/passkeys/route.ts diff --git a/apps/login/app/passkeys/verify/route.ts b/apps/login/app/api/passkeys/verify/route.ts similarity index 100% rename from apps/login/app/passkeys/verify/route.ts rename to apps/login/app/api/passkeys/verify/route.ts diff --git a/apps/login/app/registeruser/route.ts b/apps/login/app/api/registeruser/route.ts similarity index 100% rename from apps/login/app/registeruser/route.ts rename to apps/login/app/api/registeruser/route.ts diff --git a/apps/login/app/resendverifyemail/route.ts b/apps/login/app/api/resendverifyemail/route.ts similarity index 100% rename from apps/login/app/resendverifyemail/route.ts rename to apps/login/app/api/resendverifyemail/route.ts diff --git a/apps/login/app/api/session/route.ts b/apps/login/app/api/session/route.ts new file mode 100644 index 00000000000..a970ff35d6b --- /dev/null +++ b/apps/login/app/api/session/route.ts @@ -0,0 +1,120 @@ +import { server, deleteSession } from "#/lib/zitadel"; +import { + SessionCookie, + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, + removeSessionFromCookie, +} from "#/utils/cookies"; +import { + createSessionAndUpdateCookie, + setSessionAndUpdateCookie, +} from "#/utils/session"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + const body = await request.json(); + if (body) { + const { loginName, password } = body; + + const domain: string = request.nextUrl.hostname; + + return createSessionAndUpdateCookie( + loginName, + password, + domain, + undefined + ).then((session) => { + return NextResponse.json(session); + }); + } else { + return NextResponse.json( + { details: "Session could not be created" }, + { status: 500 } + ); + } +} + +/** + * + * @param request password for the most recent session + * @returns the updated most recent Session with the added password + */ +export async function PUT(request: NextRequest) { + const body = await request.json(); + + if (body) { + const { loginName, password, challenges, passkey } = body; + + const recentPromise: Promise = loginName + ? getSessionCookieByLoginName(loginName).catch((error) => { + return Promise.reject(error); + }) + : getMostRecentSessionCookie().catch((error) => { + return Promise.reject(error); + }); + + const domain: string = request.nextUrl.hostname; + + return recentPromise + .then((recent) => { + return setSessionAndUpdateCookie( + recent.id, + recent.token, + recent.loginName, + password, + passkey, + domain, + challenges + ).then((session) => { + return NextResponse.json({ + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + }); + }); + }) + .catch((error) => { + return NextResponse.json({ details: error }, { status: 500 }); + }); + } else { + return NextResponse.json( + { details: "Request body is missing" }, + { status: 400 } + ); + } +} + +/** + * + * @param request id of the session to be deleted + */ +export async function DELETE(request: NextRequest) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + if (id) { + const session = await getSessionCookieById(id); + + return deleteSession(server, session.id, session.token) + .then(() => { + return removeSessionFromCookie(session) + .then(() => { + return NextResponse.json({}); + }) + .catch((error) => { + return NextResponse.json( + { details: "could not set cookie" }, + { status: 500 } + ); + }); + }) + .catch((error) => { + return NextResponse.json( + { details: "could not delete session" }, + { status: 500 } + ); + }); + } else { + return NextResponse.error(); + } +} diff --git a/apps/login/app/verifyemail/route.ts b/apps/login/app/api/verifyemail/route.ts similarity index 100% rename from apps/login/app/verifyemail/route.ts rename to apps/login/app/api/verifyemail/route.ts diff --git a/apps/login/app/session/route.ts b/apps/login/app/session/route.ts deleted file mode 100644 index f27d80660de..00000000000 --- a/apps/login/app/session/route.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - createSession, - getSession, - server, - setSession, - deleteSession, -} from "#/lib/zitadel"; -import { - SessionCookie, - addSessionToCookie, - getMostRecentSessionCookie, - getSessionCookieById, - removeSessionFromCookie, - updateSessionCookie, -} from "#/utils/cookies"; -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(request: NextRequest) { - const body = await request.json(); - if (body) { - const { loginName, password } = body; - - const domain: string = request.nextUrl.hostname; - - const createdSession = await createSession( - server, - loginName, - password, - domain - ); - - if (createdSession) { - return getSession( - server, - createdSession.sessionId, - createdSession.sessionToken - ).then((response) => { - if (response?.session && response.session?.factors?.user?.loginName) { - const sessionCookie: SessionCookie = { - id: createdSession.sessionId, - token: createdSession.sessionToken, - changeDate: response.session.changeDate?.toString() ?? "", - loginName: response.session?.factors?.user?.loginName ?? "", - }; - return addSessionToCookie(sessionCookie).then(() => { - return NextResponse.json({ - sessionId: createdSession.sessionId, - factors: response?.session?.factors, - }); - }); - } else { - return NextResponse.json( - { - details: - "could not get session or session does not have loginName", - }, - { status: 500 } - ); - } - }); - } else { - return NextResponse.error(); - } - } else { - return NextResponse.json( - { details: "Session could not be created" }, - { status: 500 } - ); - } -} - -/** - * - * @param request password for the most recent session - * @returns the updated most recent Session with the added password - */ -export async function PUT(request: NextRequest) { - const body = await request.json(); - if (body) { - const { password } = body; - - const recent = await getMostRecentSessionCookie(); - - return setSession(server, recent.id, recent.token, password) - .then((session) => { - if (session) { - const sessionCookie: SessionCookie = { - id: recent.id, - token: session.sessionToken, - changeDate: session.details?.changeDate?.toString() ?? "", - loginName: recent.loginName, - }; - - return getSession(server, sessionCookie.id, sessionCookie.token).then( - (response) => { - if ( - response?.session && - response.session.factors?.user?.loginName - ) { - const { session } = response; - const newCookie: SessionCookie = { - id: sessionCookie.id, - token: sessionCookie.token, - changeDate: session.changeDate?.toString() ?? "", - loginName: session.factors?.user?.loginName ?? "", - }; - - return updateSessionCookie(sessionCookie.id, newCookie) - .then(() => { - return NextResponse.json({ factors: session.factors }); - }) - .catch((error) => { - return NextResponse.json( - { details: "could not set cookie" }, - { status: 500 } - ); - }); - } else { - return NextResponse.json( - { - details: - "could not get session or session does not have loginName", - }, - { status: 500 } - ); - } - } - ); - } else { - return NextResponse.json( - { details: "Session not be set" }, - { status: 500 } - ); - } - }) - .catch((error) => { - return NextResponse.json(error, { status: 500 }); - }); - } else { - return NextResponse.error(); - } -} - -/** - * - * @param request id of the session to be deleted - */ -export async function DELETE(request: NextRequest) { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - if (id) { - const session = await getSessionCookieById(id); - - return deleteSession(server, session.id, session.token) - .then(() => { - return removeSessionFromCookie(session) - .then(() => { - return NextResponse.json({ factors: session.factors }); - }) - .catch((error) => { - return NextResponse.json( - { details: "could not set cookie" }, - { status: 500 } - ); - }); - }) - .catch((error) => { - return NextResponse.json( - { details: "could not delete session" }, - { status: 500 } - ); - }); - } else { - return NextResponse.error(); - } -} diff --git a/apps/login/cypress/integration/login.cy.ts b/apps/login/cypress/integration/login.cy.ts new file mode 100644 index 00000000000..d35d006e2f3 --- /dev/null +++ b/apps/login/cypress/integration/login.cy.ts @@ -0,0 +1,112 @@ +import { stub } from "../support/mock"; + +describe("login", () => { + beforeEach(() => { + stub("zitadel.session.v2alpha.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: "2023-07-04T07:58:20.126Z", + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: + "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2alpha.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: "2023-07-04T07:58:20.026Z", + changeDate: "2023-07-04T07:58:20.126Z", + sequence: 859, + factors: { + user: { + id: "123", + loginName: "john@zitadel.com", + }, + password: undefined, + passkey: undefined, + intent: undefined, + }, + metadata: {}, + domain: "localhost", + }, + }, + }); + + stub("zitadel.settings.v2alpha.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + }, + }, + }); + }); + describe("password login", () => { + beforeEach(() => { + stub( + "zitadel.user.v2alpha.UserService", + "ListAuthenticationMethodTypes", + { + data: { + authMethodTypes: [1], // 1 for password authentication + }, + } + ); + }); + it("should redirect a user with password authentication to /password", () => { + cy.visit("/loginname?loginName=johndoe%40zitadel.com&submit=true"); + cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); + }); + describe("with passkey prompt", () => { + beforeEach(() => { + stub("zitadel.session.v2alpha.SessionService", "SetSession", { + data: { + details: { + sequence: 859, + changeDate: "2023-07-04T07:58:20.126Z", + resourceOwner: "220516472055706145", + }, + sessionToken: + "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + }); + it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); + cy.get('input[type="password"]').focus().type("MyStrongPassword!1"); + cy.get('button[type="submit"]').click(); + cy.location("pathname", { timeout: 10_000 }).should( + "eq", + "/passkey/add" + ); + }); + }); + }); + describe("passkey login", () => { + beforeEach(() => { + stub( + "zitadel.user.v2alpha.UserService", + "ListAuthenticationMethodTypes", + { + data: { + authMethodTypes: [2], // 2 for passwordless authentication + }, + } + ); + }); + it("should redirect a user with passwordless authentication to /passkey/login", () => { + cy.visit("/loginname?loginName=johndoe%40zitadel.com&submit=true"); + cy.location("pathname", { timeout: 10_000 }).should( + "eq", + "/passkey/login" + ); + }); + }); +}); diff --git a/apps/login/cypress/integration/register.cy.ts b/apps/login/cypress/integration/register.cy.ts new file mode 100644 index 00000000000..4ef537d2703 --- /dev/null +++ b/apps/login/cypress/integration/register.cy.ts @@ -0,0 +1,22 @@ +import { stub } from "../support/mock"; + +describe("register", () => { + beforeEach(() => { + stub("zitadel.user.v2alpha.UserService", "AddHumanUser", { + data: { + userId: "123", + }, + }); + }); + + it("should redirect a user who selects passwordless on register to /passkeys/add", () => { + cy.visit("/register"); + cy.get('input[autocomplete="firstname"]').focus().type("John"); + cy.get('input[autocomplete="lastname"]').focus().type("Doe"); + cy.get('input[autocomplete="email"]').focus().type("john@zitadel.com"); + cy.get('input[type="checkbox"][value="privacypolicy"]').check(); + cy.get('input[type="checkbox"][value="tos"]').check(); + cy.get('button[type="submit"]').click(); + cy.location("pathname", { timeout: 10_000 }).should("eq", "/passkey/add"); + }); +}); diff --git a/apps/login/cypress/integration/verify.cy.ts b/apps/login/cypress/integration/verify.cy.ts index 35a31cf88a8..e584b1af1d0 100644 --- a/apps/login/cypress/integration/verify.cy.ts +++ b/apps/login/cypress/integration/verify.cy.ts @@ -1,15 +1,13 @@ -import { addStub, removeStub } from "../support/mock"; +import { stub } from "../support/mock"; describe("/verify", () => { it("redirects after successful email verification", () => { - removeStub("zitadel.user.v2alpha.UserService", "VerifyEmail"); - addStub("zitadel.user.v2alpha.UserService", "VerifyEmail"); + stub("zitadel.user.v2alpha.UserService", "VerifyEmail"); cy.visit("/verify?userID=123&code=abc&submit=true"); - cy.location("pathname", { timeout: 10_000 }).should("eq", "/username"); + cy.location("pathname", { timeout: 10_000 }).should("eq", "/loginname"); }); it("shows an error if validation failed", () => { - removeStub("zitadel.user.v2alpha.UserService", "VerifyEmail"); - addStub("zitadel.user.v2alpha.UserService", "VerifyEmail", { + stub("zitadel.user.v2alpha.UserService", "VerifyEmail", { code: 3, error: "error validating code", }); diff --git a/apps/login/cypress/support/mock.ts b/apps/login/cypress/support/mock.ts index 9a7c83093d4..84c33b8c2d1 100644 --- a/apps/login/cypress/support/mock.ts +++ b/apps/login/cypress/support/mock.ts @@ -1,24 +1,25 @@ -export function removeStub(service: string, method: string) { +function removeStub(service: string, method: string) { return cy.request({ url: "http://localhost:22220/v1/stubs", method: "DELETE", qs: { - service: service, - method: method, + service, + method, }, }); } -export function addStub(service: string, method: string, out?: any) { +export function stub(service: string, method: string, out?: any) { + removeStub(service, method); return cy.request({ url: "http://localhost:22220/v1/stubs", method: "POST", body: { stubs: [ { - service: service, - method: method, - out: out, + service, + method, + out, }, ], }, diff --git a/apps/login/lib/demos.ts b/apps/login/lib/demos.ts index 7d8e5f2c937..0f224276564 100644 --- a/apps/login/lib/demos.ts +++ b/apps/login/lib/demos.ts @@ -9,8 +9,8 @@ export const demos: { name: string; items: Item[] }[] = [ name: "Login", items: [ { - name: "Username", - slug: "username", + name: "Loginname", + slug: "loginname", description: "The entrypoint of the application", }, { diff --git a/apps/login/lib/zitadel.ts b/apps/login/lib/zitadel.ts index 9531da4af27..a4cf4701ed5 100644 --- a/apps/login/lib/zitadel.ts +++ b/apps/login/lib/zitadel.ts @@ -21,6 +21,10 @@ import { SetSessionResponse, DeleteSessionResponse, VerifyPasskeyRegistrationResponse, + ChallengeKind, + LoginSettings, + GetLoginSettingsResponse, + ListAuthenticationMethodTypesResponse, } from "@zitadel/server"; export const zitadelConfig: ZitadelServerOptions = { @@ -45,6 +49,15 @@ export async function getBrandingSettings( .then((resp: GetBrandingSettingsResponse) => resp.settings); } +export async function getLoginSettings( + server: ZitadelServer +): Promise { + const settingsService = settings.getSettings(server); + return settingsService + .getLoginSettings({}, {}) + .then((resp: GetLoginSettingsResponse) => resp.settings); +} + export async function getGeneralSettings( server: ZitadelServer ): Promise { @@ -78,29 +91,47 @@ export async function getPasswordComplexitySettings( export async function createSession( server: ZitadelServer, loginName: string, + domain: string, password: string | undefined, - domain: string + challenges: ChallengeKind[] | undefined ): Promise { const sessionService = session.getSession(server); return password ? sessionService.createSession( - { checks: { user: { loginName }, password: { password } }, domain }, + { + checks: { user: { loginName }, password: { password } }, + challenges, + domain, + }, {} ) - : sessionService.createSession({ checks: { user: { loginName } } }, {}); + : sessionService.createSession( + { checks: { user: { loginName } }, domain }, + {} + ); } export async function setSession( server: ZitadelServer, sessionId: string, sessionToken: string, - password: string + domain: string | undefined, + password: string | undefined, + passkey: { credentialAssertionData: any } | undefined, + challenges: ChallengeKind[] | undefined ): Promise { const sessionService = session.getSession(server); - return sessionService.setSession( - { sessionId, sessionToken, checks: { password: { password } } }, - {} - ); + + const payload = { sessionId, sessionToken, challenges, domain }; + return password + ? sessionService.setSession( + { + ...payload, + checks: { password: { password }, passkey }, + }, + {} + ) + : sessionService.setSession(payload, {}); } export async function getSession( @@ -265,4 +296,19 @@ export async function registerPasskey( }); } +/** + * + * @param server + * @param userId the id of the user where the email should be set + * @returns the newly set email + */ +export async function listAuthenticationMethodTypes( + userId: string +): Promise { + const userservice = user.getUser(server); + return userservice.listAuthenticationMethodTypes({ + userId, + }); +} + export { server }; diff --git a/apps/login/mock/initial-stubs/zitadel.settings.v2alpha.SettingsService.json b/apps/login/mock/initial-stubs/zitadel.settings.v2alpha.SettingsService.json index 6dd6ef5af32..463f0593469 100644 --- a/apps/login/mock/initial-stubs/zitadel.settings.v2alpha.SettingsService.json +++ b/apps/login/mock/initial-stubs/zitadel.settings.v2alpha.SettingsService.json @@ -7,11 +7,29 @@ { "service": "zitadel.settings.v2alpha.SettingsService", "method": "GetLegalAndSupportSettings", - "out": {} + "out": { + "data": { + "settings": { + "tosLink": "http://whatever.com/help", + "privacyPolicyLink": "http://whatever.com/help", + "helpLink": "http://whatever.com/help" + } + } + } }, { "service": "zitadel.settings.v2alpha.SettingsService", "method": "GetPasswordComplexitySettings", - "out": {} + "out": { + "data": { + "settings": { + "minLength": 8, + "requiresUppercase": true, + "requiresLowercase": true, + "requiresNumber": true, + "requiresSymbol": true + } + } + } } ] diff --git a/apps/login/package.json b/apps/login/package.json index 9abc8c91d31..cd355da39db 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -42,13 +42,14 @@ "clsx": "1.2.1", "date-fns": "2.29.3", "moment": "^2.29.4", - "next": "13.4.2", + "next": "13.4.7", "next-themes": "^0.2.1", "nice-grpc": "2.0.1", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "7.39.5", "sass": "^1.62.0", + "swr": "^2.2.0", "tinycolor2": "1.4.2" }, "devDependencies": { diff --git a/apps/login/readme.md b/apps/login/readme.md index 9428fde3423..02bfaf62ffa 100644 --- a/apps/login/readme.md +++ b/apps/login/readme.md @@ -15,3 +15,11 @@ The Login UI should provide the following functionality: ## Documentation https://beta.nextjs.org/docs + + diff --git a/apps/login/ui/LoginPasskey.tsx b/apps/login/ui/LoginPasskey.tsx new file mode 100644 index 00000000000..ce3425afeba --- /dev/null +++ b/apps/login/ui/LoginPasskey.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Challenges_Passkey } from "@zitadel/server"; +import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64"; +import { Button, ButtonVariants } from "./Button"; +import Alert from "./Alert"; +import { Spinner } from "./Spinner"; + +type Props = { + loginName: string; + altPassword: boolean; +}; + +export default function LoginPasskey({ loginName, altPassword }: Props) { + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + const initialized = useRef(false); + + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + setLoading(true); + updateSessionForChallenge() + .then((response) => { + const pK = + response.challenges.passkey.publicKeyCredentialRequestOptions + .publicKey; + if (pK) { + submitLoginAndContinue(pK) + .then(() => { + setLoading(false); + }) + .catch((error) => { + setError(error); + setLoading(false); + }); + } else { + setError("Could not request passkey challenge"); + setLoading(false); + } + }) + .catch((error) => { + setError(error); + setLoading(false); + }); + } + }, []); + + async function updateSessionForChallenge() { + setLoading(true); + const res = await fetch("/api/session", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + loginName, + challenges: [1], // request passkey challenge + }), + }); + + setLoading(false); + if (!res.ok) { + const error = await res.json(); + throw error.details.details; + } + return res.json(); + } + + async function submitLogin(data: any) { + setLoading(true); + const res = await fetch("/api/session", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + loginName, + passkey: data, + }), + }); + + const response = await res.json(); + + setLoading(false); + if (!res.ok) { + setError(response.details); + return Promise.reject(response.details); + } + return response; + } + + async function submitLoginAndContinue( + publicKey: any + ): Promise { + publicKey.challenge = coerceToArrayBuffer( + publicKey.challenge, + "publicKey.challenge" + ); + publicKey.allowCredentials.map((listItem: any) => { + listItem.id = coerceToArrayBuffer( + listItem.id, + "publicKey.allowCredentials.id" + ); + }); + + navigator.credentials + .get({ + publicKey, + }) + .then((assertedCredential: any) => { + if (assertedCredential) { + let authData = new Uint8Array( + assertedCredential.response.authenticatorData + ); + let clientDataJSON = new Uint8Array( + assertedCredential.response.clientDataJSON + ); + let rawId = new Uint8Array(assertedCredential.rawId); + let sig = new Uint8Array(assertedCredential.response.signature); + let userHandle = new Uint8Array( + assertedCredential.response.userHandle + ); + let data = JSON.stringify({ + id: assertedCredential.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: assertedCredential.type, + response: { + authenticatorData: coerceToBase64Url(authData, "authData"), + clientDataJSON: coerceToBase64Url( + clientDataJSON, + "clientDataJSON" + ), + signature: coerceToBase64Url(sig, "sig"), + userHandle: coerceToBase64Url(userHandle, "userHandle"), + }, + }); + return submitLogin(data).then(() => { + return router.push(`/accounts`); + }); + } else { + setLoading(false); + setError("An error on retrieving passkey"); + return null; + } + }) + .catch((error) => { + console.error(error); + setLoading(false); + // setError(error); + return null; + }); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ {altPassword ? ( + + ) : ( + + )} + + + +
+
+ ); +} diff --git a/apps/login/ui/PasswordForm.tsx b/apps/login/ui/PasswordForm.tsx index 8a1d1f2f1f7..e547e801165 100644 --- a/apps/login/ui/PasswordForm.tsx +++ b/apps/login/ui/PasswordForm.tsx @@ -14,9 +14,15 @@ type Inputs = { type Props = { loginName?: string; + isAlternative?: boolean; // whether password was requested as alternative auth method + promptPasswordless?: boolean; }; -export default function PasswordForm({ loginName }: Props) { +export default function PasswordForm({ + loginName, + promptPasswordless, + isAlternative, +}: Props) { const { register, handleSubmit, formState } = useForm({ mode: "onBlur", }); @@ -30,12 +36,13 @@ export default function PasswordForm({ loginName }: Props) { async function submitPassword(values: Inputs) { setError(""); setLoading(true); - const res = await fetch("/session", { + const res = await fetch("/api/session", { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ + loginName, password: values.password, }), }); @@ -52,12 +59,17 @@ export default function PasswordForm({ loginName }: Props) { function submitPasswordAndContinue(value: Inputs): Promise { return submitPassword(value).then((resp: any) => { - if (resp.factors && !resp.factors.passwordless) { + if ( + resp.factors && + !resp.factors.passwordless && // if session was not verified with a passkey + promptPasswordless && // if explicitly prompted due policy + !isAlternative // escaped if password was used as an alternative method + ) { return router.push( `/passkey/add?` + new URLSearchParams({ loginName: resp.factors.user.loginName, - prompt: "true", + promptPasswordless: "true", }) ); } else { diff --git a/apps/login/ui/PrivacyPolicyCheckboxes.tsx b/apps/login/ui/PrivacyPolicyCheckboxes.tsx index 8addd227a19..da94e428cff 100644 --- a/apps/login/ui/PrivacyPolicyCheckboxes.tsx +++ b/apps/login/ui/PrivacyPolicyCheckboxes.tsx @@ -50,6 +50,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) { { setAcceptanceState({ ...acceptanceState, @@ -74,6 +75,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) { { setAcceptanceState({ ...acceptanceState, diff --git a/apps/login/ui/RegisterForm.tsx b/apps/login/ui/RegisterForm.tsx index 932a7294e99..6357299a1ce 100644 --- a/apps/login/ui/RegisterForm.tsx +++ b/apps/login/ui/RegisterForm.tsx @@ -48,7 +48,7 @@ export default function RegisterForm({ async function submitRegister(values: Inputs) { setLoading(true); - const res = await fetch("/registeruser", { + const res = await fetch("/api/registeruser", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/apps/login/ui/RegisterFormWithoutPassword.tsx b/apps/login/ui/RegisterFormWithoutPassword.tsx index 952b4c82eed..b75e99514c3 100644 --- a/apps/login/ui/RegisterFormWithoutPassword.tsx +++ b/apps/login/ui/RegisterFormWithoutPassword.tsx @@ -11,6 +11,7 @@ import { Spinner } from "./Spinner"; import AuthenticationMethodRadio, { methods, } from "./AuthenticationMethodRadio"; +import Alert from "./Alert"; type Inputs = | { @@ -31,12 +32,13 @@ export default function RegisterFormWithoutPassword({ legal }: Props) { const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(methods[0]); + const [error, setError] = useState(""); const router = useRouter(); async function submitAndRegister(values: Inputs) { setLoading(true); - const res = await fetch("/registeruser", { + const res = await fetch("/api/registeruser", { method: "POST", headers: { "Content-Type": "application/json", @@ -49,14 +51,15 @@ export default function RegisterFormWithoutPassword({ legal }: Props) { }); setLoading(false); if (!res.ok) { - throw new Error("Failed to register user"); + const error = await res.json(); + throw new Error(error.details); } return res.json(); } async function createSessionWithLoginName(loginName: string) { setLoading(true); - const res = await fetch("/session", { + const res = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json", @@ -79,14 +82,20 @@ export default function RegisterFormWithoutPassword({ legal }: Props) { ) { return withPassword ? router.push(`/register?` + new URLSearchParams(value)) - : submitAndRegister(value).then((resp: any) => { - createSessionWithLoginName(value.email).then(({ factors }) => { - return router.push( - `/passkey/add?` + - new URLSearchParams({ loginName: factors.user.loginName }) - ); + : submitAndRegister(value) + .then((resp: any) => { + createSessionWithLoginName(value.email).then(({ factors }) => { + setError(""); + return router.push( + `/passkey/add?` + + new URLSearchParams({ loginName: factors.user.loginName }) + ); + }); + }) + .catch((errorDetails: Error) => { + setLoading(false); + setError(errorDetails.message); }); - }); } const { errors } = formState; @@ -146,6 +155,12 @@ export default function RegisterFormWithoutPassword({ legal }: Props) { />
+ {error && ( +
+ {error} +
+ )} +
- {validPassword ? ( + {validUser ? (
) : (
diff --git a/apps/login/ui/SessionsList.tsx b/apps/login/ui/SessionsList.tsx index c3683ebfd32..0e54b8dad48 100644 --- a/apps/login/ui/SessionsList.tsx +++ b/apps/login/ui/SessionsList.tsx @@ -11,7 +11,6 @@ type Props = { export default function SessionsList({ sessions }: Props) { const [list, setList] = useState(sessions); - return sessions ? (
{list diff --git a/apps/login/ui/SetPasswordForm.tsx b/apps/login/ui/SetPasswordForm.tsx index 55aec7e388a..2efe33b8e54 100644 --- a/apps/login/ui/SetPasswordForm.tsx +++ b/apps/login/ui/SetPasswordForm.tsx @@ -47,7 +47,7 @@ export default function SetPasswordForm({ async function submitRegister(values: Inputs) { setLoading(true); - const res = await fetch("/registeruser", { + const res = await fetch("/api/registeruser", { method: "POST", headers: { "Content-Type": "application/json", @@ -71,7 +71,7 @@ export default function SetPasswordForm({ loginName: string, password: string ) { - const res = await fetch("/session", { + const res = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/apps/login/ui/UsernameForm.tsx b/apps/login/ui/UsernameForm.tsx index 1e63dcb560c..729b418edf5 100644 --- a/apps/login/ui/UsernameForm.tsx +++ b/apps/login/ui/UsernameForm.tsx @@ -1,28 +1,43 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button, ButtonVariants } from "./Button"; import { TextInput } from "./Input"; import { useForm } from "react-hook-form"; import { useRouter } from "next/navigation"; import { Spinner } from "./Spinner"; +import { LoginSettings } from "@zitadel/server"; type Inputs = { loginName: string; }; -export default function UsernameForm() { +type Props = { + loginSettings: LoginSettings | undefined; + loginName: string | undefined; + submit: boolean; +}; + +export default function UsernameForm({ + loginSettings, + loginName, + submit, +}: Props) { const { register, handleSubmit, formState } = useForm({ mode: "onBlur", + defaultValues: { + loginName: loginName ? loginName : "", + }, }); - const [loading, setLoading] = useState(false); - const router = useRouter(); - async function submitUsername(values: Inputs) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function submitLoginName(values: Inputs) { setLoading(true); - const res = await fetch("/session", { + const res = await fetch("/api/loginname", { method: "POST", headers: { "Content-Type": "application/json", @@ -34,22 +49,77 @@ export default function UsernameForm() { setLoading(false); if (!res.ok) { - throw new Error("Failed to set user"); + throw new Error("Failed to load authentication methods"); } return res.json(); } - function submitUsernameAndContinue(value: Inputs): Promise { - return submitUsername(value).then(({ factors }) => { - return router.push( - `/password?` + - new URLSearchParams({ loginName: `${factors.user.loginName}` }) - ); + async function setLoginNameAndGetAuthMethods(values: Inputs) { + return submitLoginName(values).then((response) => { + if (response.authMethodTypes.length == 1) { + const method = response.authMethodTypes[0]; + switch (method) { + case 1: //AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSWORD: + return router.push( + "/password?" + + new URLSearchParams( + loginSettings?.passkeysType === 1 + ? { + loginName: values.loginName, + promptPasswordless: `true`, // PasskeysType.PASSKEYS_TYPE_ALLOWED, + } + : { loginName: values.loginName } + ) + ); + case 2: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY + return router.push( + "/passkey/login?" + + new URLSearchParams({ loginName: values.loginName }) + ); + default: + return router.push( + "/password?" + + new URLSearchParams( + loginSettings?.passkeysType === 1 + ? { + loginName: values.loginName, + promptPasswordless: `true`, // PasskeysType.PASSKEYS_TYPE_ALLOWED, + } + : { loginName: values.loginName } + ) + ); + } + } else if ( + response.authMethodTypes && + response.authMethodTypes.length === 0 + ) { + setError( + "User has no available authentication methods. Contact your administrator to setup authentication for the requested user." + ); + } else { + // prefer passkey in favor of other methods + if (response.authMethodTypes.includes(2)) { + return router.push( + "/passkey/login?" + + new URLSearchParams({ + loginName: values.loginName, + altPassword: `${response.authMethodTypes.includes(1)}`, // show alternative password option + }) + ); + } + } }); } const { errors } = formState; + useEffect(() => { + if (submit && loginName) { + // When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid. + setLoginNameAndGetAuthMethods({ loginName }); + } + }, []); + return (
@@ -72,7 +142,7 @@ export default function UsernameForm() { className="self-end" variant={ButtonVariants.Primary} disabled={loading || !formState.isValid} - onClick={handleSubmit(submitUsernameAndContinue)} + onClick={handleSubmit(setLoginNameAndGetAuthMethods)} > {loading && } continue diff --git a/apps/login/ui/VerifyEmailForm.tsx b/apps/login/ui/VerifyEmailForm.tsx index 219339a249e..b4b81dd50df 100644 --- a/apps/login/ui/VerifyEmailForm.tsx +++ b/apps/login/ui/VerifyEmailForm.tsx @@ -42,7 +42,7 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) { async function submitCode(values: Inputs) { setLoading(true); - const res = await fetch("/verifyemail", { + const res = await fetch("/api/verifyemail", { method: "POST", headers: { "Content-Type": "application/json", @@ -66,7 +66,7 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) { async function resendCode() { setLoading(true); - const res = await fetch("/resendverifyemail", { + const res = await fetch("/api/resendverifyemail", { method: "POST", headers: { "Content-Type": "application/json", @@ -87,7 +87,7 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) { function submitCodeAndContinue(value: Inputs): Promise { return submitCode(value).then((resp: any) => { - return router.push(`/username`); + return router.push(`/loginname`); }); } diff --git a/apps/login/utils/colors.ts b/apps/login/utils/colors.ts index 75b1c8c3568..817f3eb364a 100644 --- a/apps/login/utils/colors.ts +++ b/apps/login/utils/colors.ts @@ -72,16 +72,16 @@ type BrandingColors = { export function setTheme(document: any, policy?: Partial) { const lP: BrandingColors = { lightTheme: { - backgroundColor: policy?.lightTheme?.backgroundColor ?? BACKGROUND, - fontColor: policy?.lightTheme?.fontColor ?? TEXT, - primaryColor: policy?.lightTheme?.primaryColor ?? PRIMARY, - warnColor: policy?.lightTheme?.warnColor ?? WARN, + backgroundColor: policy?.lightTheme?.backgroundColor || BACKGROUND, + fontColor: policy?.lightTheme?.fontColor || TEXT, + primaryColor: policy?.lightTheme?.primaryColor || PRIMARY, + warnColor: policy?.lightTheme?.warnColor || WARN, }, darkTheme: { - backgroundColor: policy?.darkTheme?.backgroundColor ?? DARK_BACKGROUND, - fontColor: policy?.darkTheme?.fontColor ?? DARK_TEXT, - primaryColor: policy?.darkTheme?.primaryColor ?? DARK_PRIMARY, - warnColor: policy?.darkTheme?.warnColor ?? DARK_WARN, + backgroundColor: policy?.darkTheme?.backgroundColor || DARK_BACKGROUND, + fontColor: policy?.darkTheme?.fontColor || DARK_TEXT, + primaryColor: policy?.darkTheme?.primaryColor || DARK_PRIMARY, + warnColor: policy?.darkTheme?.warnColor || DARK_WARN, }, }; diff --git a/apps/login/utils/cookies.ts b/apps/login/utils/cookies.ts index fded8ed392a..fd6a6f47f89 100644 --- a/apps/login/utils/cookies.ts +++ b/apps/login/utils/cookies.ts @@ -19,6 +19,7 @@ function setSessionHttpOnlyCookie(sessions: SessionCookie[]) { path: "/", }); } + export async function addSessionToCookie(session: SessionCookie): Promise { const cookiesList = cookies(); const stringifiedCookie = cookiesList.get("sessions"); @@ -37,7 +38,7 @@ export async function addSessionToCookie(session: SessionCookie): Promise { currentSessions = [...currentSessions, session]; } - setSessionHttpOnlyCookie(currentSessions); + return setSessionHttpOnlyCookie(currentSessions); } export async function updateSessionCookie( @@ -52,9 +53,12 @@ export async function updateSessionCookie( : [session]; const foundIndex = sessions.findIndex((session) => session.id === id); - sessions[foundIndex] = session; - - return setSessionHttpOnlyCookie(sessions); + if (foundIndex > -1) { + sessions[foundIndex] = session; + return setSessionHttpOnlyCookie(sessions); + } else { + throw "updateSessionCookie: session id now found"; + } } export async function removeSessionFromCookie( @@ -88,11 +92,11 @@ export async function getMostRecentSessionCookie(): Promise { return latest; } else { - return Promise.reject(); + return Promise.reject("no session cookie found"); } } -export async function getSessionCookieById(id: string): Promise { +export async function getSessionCookieById(id: string): Promise { const cookiesList = cookies(); const stringifiedCookie = cookiesList.get("sessions"); @@ -110,6 +114,26 @@ export async function getSessionCookieById(id: string): Promise { } } +export async function getSessionCookieByLoginName( + loginName: string +): Promise { + const cookiesList = cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + const found = sessions.find((s) => s.loginName === loginName); + if (found) { + return found; + } else { + return Promise.reject("no cookie found with loginName: " + loginName); + } + } else { + return Promise.reject("no session cookie found"); + } +} + export async function getAllSessionIds(): Promise { const cookiesList = cookies(); const stringifiedCookie = cookiesList.get("sessions"); diff --git a/apps/login/utils/session.ts b/apps/login/utils/session.ts new file mode 100644 index 00000000000..374f3153715 --- /dev/null +++ b/apps/login/utils/session.ts @@ -0,0 +1,102 @@ +import { createSession, getSession, server, setSession } from "#/lib/zitadel"; +import { + SessionCookie, + addSessionToCookie, + updateSessionCookie, +} from "./cookies"; +import { ChallengeKind, Session, Challenges } from "@zitadel/server"; + +export async function createSessionAndUpdateCookie( + loginName: string, + password: string | undefined, + domain: string, + challenges: ChallengeKind[] | undefined +): Promise { + const createdSession = await createSession( + server, + loginName, + domain, + password, + challenges + ); + + if (createdSession) { + return getSession( + server, + createdSession.sessionId, + createdSession.sessionToken + ).then((response) => { + if (response?.session && response.session?.factors?.user?.loginName) { + const sessionCookie: SessionCookie = { + id: createdSession.sessionId, + token: createdSession.sessionToken, + changeDate: response.session.changeDate?.toString() ?? "", + loginName: response.session?.factors?.user?.loginName ?? "", + }; + + return addSessionToCookie(sessionCookie).then(() => { + return response.session as Session; + }); + } else { + throw "could not get session or session does not have loginName"; + } + }); + } else { + throw "Could not create session"; + } +} + +export type SessionWithChallenges = Session & { + challenges: Challenges | undefined; +}; + +export async function setSessionAndUpdateCookie( + sessionId: string, + sessionToken: string, + loginName: string, + password: string | undefined, + passkey: { credentialAssertionData: any } | undefined, + domain: string | undefined, + challenges: ChallengeKind[] | undefined +): Promise { + return setSession( + server, + sessionId, + sessionToken, + domain, + password, + passkey, + challenges + ).then((updatedSession) => { + if (updatedSession) { + const sessionCookie: SessionCookie = { + id: sessionId, + token: updatedSession.sessionToken, + changeDate: updatedSession.details?.changeDate?.toString() ?? "", + loginName: loginName, + }; + + return getSession(server, sessionCookie.id, sessionCookie.token).then( + (response) => { + if (response?.session && response.session.factors?.user?.loginName) { + const { session } = response; + const newCookie: SessionCookie = { + id: sessionCookie.id, + token: updatedSession.sessionToken, + changeDate: session.changeDate?.toString() ?? "", + loginName: session.factors?.user?.loginName ?? "", + }; + + return updateSessionCookie(sessionCookie.id, newCookie).then(() => { + return { challenges: updatedSession.challenges, ...session }; + }); + } else { + throw "could not get session or session does not have loginName"; + } + } + ); + } else { + throw "Session not be set"; + } + }); +} diff --git a/packages/zitadel-server/src/index.ts b/packages/zitadel-server/src/index.ts index ba0fbb49c9c..a8bf5cf7285 100644 --- a/packages/zitadel-server/src/index.ts +++ b/packages/zitadel-server/src/index.ts @@ -12,7 +12,18 @@ export { Theme, } from "./proto/server/zitadel/settings/v2alpha/branding_settings"; -export { Session } from "./proto/server/zitadel/session/v2alpha/session"; +export { LoginSettings } from "./proto/server/zitadel/settings/v2alpha/login_settings"; + +export { + ChallengeKind, + Challenges, + Challenges_Passkey, +} from "./proto/server/zitadel/session/v2alpha/challenge"; + +export { + Session, + Factors, +} from "./proto/server/zitadel/session/v2alpha/session"; export { ListSessionsResponse, GetSessionResponse, @@ -25,6 +36,8 @@ export { GetBrandingSettingsResponse, GetLegalAndSupportSettingsResponse, GetGeneralSettingsResponse, + GetLoginSettingsResponse, + GetLoginSettingsRequest, } from "./proto/server/zitadel/settings/v2alpha/settings_service"; export { AddHumanUserResponse, @@ -35,6 +48,9 @@ export { RegisterPasskeyResponse, CreatePasskeyRegistrationLinkResponse, CreatePasskeyRegistrationLinkRequest, + ListAuthenticationMethodTypesResponse, + ListAuthenticationMethodTypesRequest, + AuthenticationMethodType, } from "./proto/server/zitadel/user/v2alpha/user_service"; export { SetHumanPasswordResponse, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f88d338cd9f..2fb698abc83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,7 +54,7 @@ importers: lint-staged: 13.0.3 make-dir-cli: 3.0.0 moment: ^2.29.4 - next: 13.4.2 + next: 13.4.7 next-themes: ^0.2.1 nice-grpc: 2.0.1 nodemon: ^2.0.22 @@ -65,6 +65,7 @@ importers: react-hook-form: 7.39.5 sass: ^1.62.0 start-server-and-test: ^2.0.0 + swr: ^2.2.0 tailwindcss: 3.2.4 tinycolor2: 1.4.2 ts-jest: ^29.1.0 @@ -83,13 +84,14 @@ importers: clsx: 1.2.1 date-fns: 2.29.3 moment: 2.29.4 - next: 13.4.2_bteaqif5pz4zkv4h4kccxuyuxu - next-themes: 0.2.1_cmp7sjki5xcmfyvhcokzzink7a + next: 13.4.7_bteaqif5pz4zkv4h4kccxuyuxu + next-themes: 0.2.1_5v7kwk6wz2hvmegftnptprfjvm nice-grpc: 2.0.1 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 react-hook-form: 7.39.5_react@18.2.0 sass: 1.62.0 + swr: 2.2.0_react@18.2.0 tinycolor2: 1.4.2 devDependencies: '@bufbuild/buf': 1.15.0 @@ -108,7 +110,7 @@ importers: '@zitadel/tsconfig': link:../../packages/zitadel-tsconfig autoprefixer: 10.4.13_postcss@8.4.21 concurrently: 8.2.0 - cypress: 12.15.0 + cypress: 12.16.0 del-cli: 5.0.0 env-cmd: 10.1.0 eslint-config-zitadel: link:../../packages/eslint-config-zitadel @@ -136,9 +138,9 @@ importers: eslint-config-turbo: latest eslint-plugin-react: 7.28.0 dependencies: - eslint-config-next: 13.4.7_dewl7jrzrufmm6i6j6pp2pqhja + eslint-config-next: 13.4.8_dewl7jrzrufmm6i6j6pp2pqhja eslint-config-prettier: 8.5.0_eslint@8.28.0 - eslint-config-turbo: 1.10.6_eslint@8.28.0 + eslint-config-turbo: 1.10.7_eslint@8.28.0 eslint-plugin-react: 7.28.0_eslint@8.28.0 packages/zitadel-client: @@ -1386,12 +1388,12 @@ packages: resolution: {integrity: sha512-FN50r/E+b8wuqyRjmGaqvqNDuWBWYWQiigfZ50KnSFH0f+AMQQyaZl+Zm2+CIpKk0fL9QxhLxOpTVA3xFHgFow==} dev: false - /@next/env/13.4.2: - resolution: {integrity: sha512-Wqvo7lDeS0KGwtwg9TT9wKQ8raelmUxt+TQKWvG/xKfcmDXNOtCuaszcfCF8JzlBG1q0VhpI6CKaRMbVPMDWgw==} + /@next/env/13.4.7: + resolution: {integrity: sha512-ZlbiFulnwiFsW9UV1ku1OvX/oyIPLtMk9p/nnvDSwI0s7vSoZdRtxXNsaO+ZXrLv/pMbXVGq4lL8TbY9iuGmVw==} dev: false - /@next/eslint-plugin-next/13.4.7: - resolution: {integrity: sha512-ANEPltxzXbyyG7CvqxdY4PmeM5+RyWdAJGufTHnU+LA/i3J6IDV2r8Z4onKwskwKEhwqzz5lMaSYGGXLyHX+mg==} + /@next/eslint-plugin-next/13.4.8: + resolution: {integrity: sha512-cmfVHpxWjjcETFt2WHnoFU6EmY69QcPJRlRNAooQlNe53Ke90vg1Ci/dkPffryJZaxxiRziP9bQrV8lDVCn3Fw==} dependencies: glob: 7.1.7 dev: false @@ -1423,8 +1425,8 @@ packages: dev: false optional: true - /@next/swc-darwin-arm64/13.4.2: - resolution: {integrity: sha512-6BBlqGu3ewgJflv9iLCwO1v1hqlecaIH2AotpKfVUEzUxuuDNJQZ2a4KLb4MBl8T9/vca1YuWhSqtbF6ZuUJJw==} + /@next/swc-darwin-arm64/13.4.7: + resolution: {integrity: sha512-VZTxPv1b59KGiv/pZHTO5Gbsdeoxcj2rU2cqJu03btMhHpn3vwzEK0gUSVC/XW96aeGO67X+cMahhwHzef24/w==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1441,8 +1443,8 @@ packages: dev: false optional: true - /@next/swc-darwin-x64/13.4.2: - resolution: {integrity: sha512-iZuYr7ZvGLPjPmfhhMl0ISm+z8EiyLBC1bLyFwGBxkWmPXqdJ60mzuTaDSr5WezDwv0fz32HB7JHmRC6JVHSZg==} + /@next/swc-darwin-x64/13.4.7: + resolution: {integrity: sha512-gO2bw+2Ymmga+QYujjvDz9955xvYGrWofmxTq7m70b9pDPvl7aDFABJOZ2a8SRCuSNB5mXU8eTOmVVwyp/nAew==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1477,8 +1479,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-gnu/13.4.2: - resolution: {integrity: sha512-2xVabFtIge6BJTcJrW8YuUnYTuQjh4jEuRuS2mscyNVOj6zUZkom3CQg+egKOoS+zh2rrro66ffSKIS+ztFJTg==} + /@next/swc-linux-arm64-gnu/13.4.7: + resolution: {integrity: sha512-6cqp3vf1eHxjIDhEOc7Mh/s8z1cwc/l5B6ZNkOofmZVyu1zsbEM5Hmx64s12Rd9AYgGoiCz4OJ4M/oRnkE16/Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1495,8 +1497,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-musl/13.4.2: - resolution: {integrity: sha512-wKRCQ27xCUJx5d6IivfjYGq8oVngqIhlhSAJntgXLt7Uo9sRT/3EppMHqUZRfyuNBTbykEre1s5166z+pvRB5A==} + /@next/swc-linux-arm64-musl/13.4.7: + resolution: {integrity: sha512-T1kD2FWOEy5WPidOn1si0rYmWORNch4a/NR52Ghyp4q7KyxOCuiOfZzyhVC5tsLIBDH3+cNdB5DkD9afpNDaOw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1513,8 +1515,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-gnu/13.4.2: - resolution: {integrity: sha512-NpCa+UVhhuNeaFVUP1Bftm0uqtvLWq2JTm7+Ta48+2Uqj2mNXrDIvyn1DY/ZEfmW/1yvGBRaUAv9zkMkMRixQA==} + /@next/swc-linux-x64-gnu/13.4.7: + resolution: {integrity: sha512-zaEC+iEiAHNdhl6fuwl0H0shnTzQoAoJiDYBUze8QTntE/GNPfTYpYboxF5LRYIjBwETUatvE0T64W6SKDipvg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1531,8 +1533,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-musl/13.4.2: - resolution: {integrity: sha512-ZWVC72x0lW4aj44e3khvBrj2oSYj1bD0jESmyah3zG/3DplEy/FOtYkMzbMjHTdDSheso7zH8GIlW6CDQnKhmQ==} + /@next/swc-linux-x64-musl/13.4.7: + resolution: {integrity: sha512-X6r12F8d8SKAtYJqLZBBMIwEqcTRvUdVm+xIq+l6pJqlgT2tNsLLf2i5Cl88xSsIytBICGsCNNHd+siD2fbWBA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1549,8 +1551,8 @@ packages: dev: false optional: true - /@next/swc-win32-arm64-msvc/13.4.2: - resolution: {integrity: sha512-pLT+OWYpzJig5K4VKhLttlIfBcVZfr2+Xbjra0Tjs83NQSkFS+y7xx+YhCwvpEmXYLIvaggj2ONPyjbiigOvHQ==} + /@next/swc-win32-arm64-msvc/13.4.7: + resolution: {integrity: sha512-NPnmnV+vEIxnu6SUvjnuaWRglZzw4ox5n/MQTxeUhb5iwVWFedolPFebMNwgrWu4AELwvTdGtWjqof53AiWHcw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -1567,8 +1569,8 @@ packages: dev: false optional: true - /@next/swc-win32-ia32-msvc/13.4.2: - resolution: {integrity: sha512-dhpiksQCyGca4WY0fJyzK3FxMDFoqMb0Cn+uDB+9GYjpU2K5//UGPQlCwiK4JHxuhg8oLMag5Nf3/IPSJNG8jw==} + /@next/swc-win32-ia32-msvc/13.4.7: + resolution: {integrity: sha512-6Hxijm6/a8XqLQpOOf/XuwWRhcuc/g4rBB2oxjgCMuV9Xlr2bLs5+lXyh8w9YbAUMYR3iC9mgOlXbHa79elmXw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] @@ -1585,8 +1587,8 @@ packages: dev: false optional: true - /@next/swc-win32-x64-msvc/13.4.2: - resolution: {integrity: sha512-O7bort1Vld00cu8g0jHZq3cbSTUNMohOEvYqsqE10+yfohhdPHzvzO+ziJRz4Dyyr/fYKREwS7gR4JC0soSOMw==} + /@next/swc-win32-x64-msvc/13.4.7: + resolution: {integrity: sha512-sW9Yt36Db1nXJL+mTr2Wo0y+VkPWeYhygvcHj1FF0srVtV+VoDjxleKtny21QHaG05zdeZnw2fCtf2+dEqgwqA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1866,8 +1868,8 @@ packages: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} dev: true - /@types/node/14.18.51: - resolution: {integrity: sha512-P9bsdGFPpVtofEKlhWMVS2qqx1A/rt9QBfihWlklfHHpUpjtYse5AzFz6j4DWrARLYh6gRnw9+5+DJcrq3KvBA==} + /@types/node/14.18.53: + resolution: {integrity: sha512-soGmOpVBUq+gaBMwom1M+krC/NNbWlosh4AtGA03SyWNDiqSKtwp7OulO1M6+mg8YkHMvJ/y0AkCeO8d1hNb7A==} dev: true /@types/node/18.11.9: @@ -2966,15 +2968,15 @@ packages: stream-transform: 2.1.3 dev: true - /cypress/12.15.0: - resolution: {integrity: sha512-FqGbxsH+QgjStuTO9onXMIeF44eOrgVwPvlcvuzLIaePQMkl72YgBvpuHlBGRcrw3Q4SvqKfajN8iV5XWShAiQ==} + /cypress/12.16.0: + resolution: {integrity: sha512-mwv1YNe48hm0LVaPgofEhGCtLwNIQEjmj2dJXnAkY1b4n/NE9OtgPph4TyS+tOtYp5CKtRmDvBzWseUXQTjbTg==} engines: {node: ^14.0.0 || ^16.0.0 || >=18.0.0} hasBin: true requiresBuild: true dependencies: '@cypress/request': 2.88.11 '@cypress/xvfb': 1.2.4_supports-color@8.1.1 - '@types/node': 14.18.51 + '@types/node': 14.18.53 '@types/sinonjs__fake-timers': 8.1.1 '@types/sizzle': 2.3.3 arch: 2.2.0 @@ -2988,7 +2990,7 @@ packages: cli-table3: 0.6.3 commander: 6.2.1 common-tags: 1.8.2 - dayjs: 1.11.8 + dayjs: 1.11.9 debug: 4.3.4_supports-color@8.1.1 enquirer: 2.3.6 eventemitter2: 6.4.7 @@ -3052,8 +3054,8 @@ packages: '@babel/runtime': 7.22.3 dev: true - /dayjs/1.11.8: - resolution: {integrity: sha512-LcgxzFoWMEPO7ggRv1Y2N31hUf2R0Vj7fuy/m+Bg1K8rr+KAs1AEy4y9jd5DXe8pbHgX+srkHNS7TH6Q6ZhYeQ==} + /dayjs/1.11.9: + resolution: {integrity: sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==} dev: true /debug/2.6.9: @@ -3692,8 +3694,8 @@ packages: source-map: 0.6.1 dev: true - /eslint-config-next/13.4.7_dewl7jrzrufmm6i6j6pp2pqhja: - resolution: {integrity: sha512-+IRAyD0+J1MZaTi9RQMPUfr6Q+GCZ1wOkK6XM52Vokh7VI4R6YFGOFzdkEFHl4ZyIX4FKa5vcwUP2WscSFNjNQ==} + /eslint-config-next/13.4.8_dewl7jrzrufmm6i6j6pp2pqhja: + resolution: {integrity: sha512-2hE0b6lHuhtHBX8VgEXi8v4G8PVrPUBMOSLCTq8qtcQ2qQOX7+uBOLK2kU4FD2qDZzyXNlhmuH+WLT5ptY4XLA==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 typescript: '>=3.3.1' @@ -3701,7 +3703,7 @@ packages: typescript: optional: true dependencies: - '@next/eslint-plugin-next': 13.4.7 + '@next/eslint-plugin-next': 13.4.8 '@rushstack/eslint-patch': 1.2.0 '@typescript-eslint/parser': 5.44.0_dewl7jrzrufmm6i6j6pp2pqhja eslint: 8.28.0 @@ -3726,13 +3728,13 @@ packages: eslint: 8.28.0 dev: false - /eslint-config-turbo/1.10.6_eslint@8.28.0: - resolution: {integrity: sha512-iZ63etePRUdEIDY5MxdUhU2ekV9TDbVdHg0BK00QqVFgQTXUYuJ7rsQj/wUKTsw3jwhbLfaY6H5sknAgYyWZ2g==} + /eslint-config-turbo/1.10.7_eslint@8.28.0: + resolution: {integrity: sha512-0yHt5UlXVph8S4SOvP6gYehLvYjJj6XFKTYOG/WUQbjlcF0OU4pOT1a1juqmmBPWYlvJ0evt7v+RekY4tOopPQ==} peerDependencies: eslint: '>6.6.0' dependencies: eslint: 8.28.0 - eslint-plugin-turbo: 1.10.6_eslint@8.28.0 + eslint-plugin-turbo: 1.10.7_eslint@8.28.0 dev: false /eslint-import-resolver-node/0.3.6: @@ -3900,8 +3902,8 @@ packages: string.prototype.matchall: 4.0.8 dev: false - /eslint-plugin-turbo/1.10.6_eslint@8.28.0: - resolution: {integrity: sha512-jlzfxYaK8hcz1DTk8Glxxi1r0kgdy85191a4CbFOTiiBulmKHMLJgzhsyE9Ong796MA62n91KFpc20BiKjlHwg==} + /eslint-plugin-turbo/1.10.7_eslint@8.28.0: + resolution: {integrity: sha512-YikBHc75DY9VV1vAFUIBekHLQlxqVT5zTNibK8zBQInCUhF7PvyPJc0xXw5FSz8EYtt4uOV3r0Km3CmFRclS4Q==} peerDependencies: eslint: '>6.6.0' dependencies: @@ -4504,6 +4506,10 @@ packages: dependencies: is-glob: 4.0.3 + /glob-to-regexp/0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + dev: false + /glob/7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} dependencies: @@ -5210,7 +5216,7 @@ packages: pretty-format: 29.5.0 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.1_d2gyhxeuxd6tnmgqdyixls47tq + ts-node: 10.9.1_wup25etrarvlqkprac7h35hj7u transitivePeerDependencies: - supports-color dev: true @@ -5844,7 +5850,7 @@ packages: log-update: 4.0.0 p-map: 4.0.0 rfdc: 1.3.0 - rxjs: 7.8.1 + rxjs: 7.8.0 through: 2.3.8 wrap-ansi: 7.0.0 dev: true @@ -6160,14 +6166,14 @@ packages: /natural-compare/1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - /next-themes/0.2.1_cmp7sjki5xcmfyvhcokzzink7a: + /next-themes/0.2.1_5v7kwk6wz2hvmegftnptprfjvm: resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} peerDependencies: next: '*' react: '*' react-dom: '*' dependencies: - next: 13.4.2_bteaqif5pz4zkv4h4kccxuyuxu + next: 13.4.7_bteaqif5pz4zkv4h4kccxuyuxu react: 18.2.0 react-dom: 18.2.0_react@18.2.0 dev: false @@ -6219,14 +6225,13 @@ packages: - babel-plugin-macros dev: false - /next/13.4.2_bteaqif5pz4zkv4h4kccxuyuxu: - resolution: {integrity: sha512-aNFqLs3a3nTGvLWlO9SUhCuMUHVPSFQC0+tDNGAsDXqx+WJDFSbvc233gOJ5H19SBc7nw36A9LwQepOJ2u/8Kg==} + /next/13.4.7_bteaqif5pz4zkv4h4kccxuyuxu: + resolution: {integrity: sha512-M8z3k9VmG51SRT6v5uDKdJXcAqLzP3C+vaKfLIAM0Mhx1um1G7MDnO63+m52qPdZfrTFzMZNzfsgvm3ghuVHIQ==} engines: {node: '>=16.8.0'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 fibers: '>= 3.1.0' - node-sass: ^6.0.0 || ^7.0.0 react: ^18.2.0 react-dom: ^18.2.0 sass: ^1.3.0 @@ -6235,12 +6240,10 @@ packages: optional: true fibers: optional: true - node-sass: - optional: true sass: optional: true dependencies: - '@next/env': 13.4.2 + '@next/env': 13.4.7 '@swc/helpers': 0.5.1 busboy: 1.6.0 caniuse-lite: 1.0.30001473 @@ -6249,17 +6252,18 @@ packages: react-dom: 18.2.0_react@18.2.0 sass: 1.62.0 styled-jsx: 5.1.1_cealaxz4az2u5bjp2e6aea3kui + watchpack: 2.4.0 zod: 3.21.4 optionalDependencies: - '@next/swc-darwin-arm64': 13.4.2 - '@next/swc-darwin-x64': 13.4.2 - '@next/swc-linux-arm64-gnu': 13.4.2 - '@next/swc-linux-arm64-musl': 13.4.2 - '@next/swc-linux-x64-gnu': 13.4.2 - '@next/swc-linux-x64-musl': 13.4.2 - '@next/swc-win32-arm64-msvc': 13.4.2 - '@next/swc-win32-ia32-msvc': 13.4.2 - '@next/swc-win32-x64-msvc': 13.4.2 + '@next/swc-darwin-arm64': 13.4.7 + '@next/swc-darwin-x64': 13.4.7 + '@next/swc-linux-arm64-gnu': 13.4.7 + '@next/swc-linux-arm64-musl': 13.4.7 + '@next/swc-linux-x64-gnu': 13.4.7 + '@next/swc-linux-x64-musl': 13.4.7 + '@next/swc-win32-arm64-msvc': 13.4.7 + '@next/swc-win32-ia32-msvc': 13.4.7 + '@next/swc-win32-x64-msvc': 13.4.7 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -7199,6 +7203,12 @@ packages: dependencies: queue-microtask: 1.2.3 + /rxjs/7.8.0: + resolution: {integrity: sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==} + dependencies: + tslib: 2.4.1 + dev: true + /rxjs/7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} dependencies: @@ -7658,6 +7668,15 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /swr/2.2.0_react@18.2.0: + resolution: {integrity: sha512-AjqHOv2lAhkuUdIiBu9xbuettzAzWXmCEcLONNKJRba87WAefz8Ca9d6ds/SzrPc235n1IxWYdhJ2zF3MNUaoQ==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + use-sync-external-store: 1.2.0_react@18.2.0 + dev: false + /symbol-tree/3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true @@ -8358,6 +8377,14 @@ packages: requires-port: 1.0.0 dev: true + /use-sync-external-store/1.2.0_react@18.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /util-deprecate/1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -8425,6 +8452,14 @@ packages: makeerror: 1.0.12 dev: true + /watchpack/2.4.0: + resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} + engines: {node: '>=10.13.0'} + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.10 + dev: false + /wcwidth/1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} dependencies: