diff --git a/shared/studio/tabs/auth/index.tsx b/shared/studio/tabs/auth/index.tsx index 6a4d7821..fa34e832 100644 --- a/shared/studio/tabs/auth/index.tsx +++ b/shared/studio/tabs/auth/index.tsx @@ -32,6 +32,8 @@ import { DraftAppConfig, AbstractDraftConfig, _providersInfo, + LocalWebAuthnProviderData, + LocalMagicLinkProviderData, } from "./state"; import {encodeB64} from "edgedb/dist/primitives/buffer"; @@ -803,6 +805,7 @@ const UIConfigForm = observer(function UIConfig({ ) : providerKind === "Local" ? ( -
-
require_verification
-
-
- + <> + {draftState.selectedProviderType === + "ext::auth::WebAuthnProviderConfig" ? ( +
+
relying_party_origin
+
+
+ + draftState.setWebauthnRelyingOrigin(val) + } + error={draftState.webauthnRelyingOriginError} + /> +
+
+ The full origin of the sign-in page including protocol and + port of the application. If using the built-in UI, this + should be the origin of the EdgeDB server. +
+
-
- Whether the email needs to be verified before the user is - allowed to sign in. + ) : null} + {draftState.selectedProviderType === + "ext::auth::EmailPasswordProviderConfig" || + draftState.selectedProviderType === + "ext::auth::WebAuthnProviderConfig" ? ( +
+
require_verification
+
+
+ +
+
+ Whether the email needs to be verified before the user is + allowed to sign in. +
+
-
-
+ ) : null} + {draftState.selectedProviderType === + "ext::auth::MagicLinkProviderConfig" ? ( +
+
token_time_to_live
+
+
+ + draftState.setTokenTimeToLive(val.toUpperCase()) + } + error={draftState.tokenTimeToLiveError} + /> +
+
+ The time after which a magic link token expires. Defaults + to 10 minutes. +
+
+
+ ) : null} + ) : null}
@@ -1106,14 +1163,46 @@ function ProviderCard({provider}: {provider: AuthProviderData}) { ) : kind === "Local" ? ( <> -
- require_verification -
-
- {( - provider as LocalEmailPasswordProviderData - ).require_verification.toString()} -
+ {provider.name === "builtin::local_webauthn" ? ( + <> +
+ relying_party_origin +
+
+ { + (provider as LocalWebAuthnProviderData) + .relying_party_origin + } +
+ + ) : null} + {provider.name === "builtin::local_emailpassword" || + provider.name === "builtin::local_webauthn" ? ( + <> +
+ require_verification +
+
+ {( + provider as + | LocalEmailPasswordProviderData + | LocalWebAuthnProviderData + ).require_verification.toString()} +
+ + ) : null} + {provider.name === "builtin::local_magic_link" ? ( + <> +
time_to_live
+
+ { + (provider as LocalMagicLinkProviderData) + .token_time_to_live + } + s +
+ + ) : null} ) : null}
diff --git a/shared/studio/tabs/auth/loginUIPreview.tsx b/shared/studio/tabs/auth/loginUIPreview.tsx index b4468c77..06ccc05d 100644 --- a/shared/studio/tabs/auth/loginUIPreview.tsx +++ b/shared/studio/tabs/auth/loginUIPreview.tsx @@ -1,8 +1,14 @@ +import {useEffect, useRef, useState} from "react"; import "@fontsource-variable/roboto-flex"; import cn from "@edgedb/common/utils/classNames"; -import {AuthProviderData, DraftAppConfig, _providersInfo} from "./state"; +import { + AuthProviderData, + DraftAppConfig, + OAuthProviderData, + _providersInfo, +} from "./state"; import styles from "./loginuipreview.module.scss"; import {getColourVariables, normaliseHexColor} from "./colourUtils"; @@ -23,9 +29,38 @@ export function LoginUIPreview({ const colorVariables = getColourVariables(normaliseHexColor(brandColor)); - const hasPasswordProvider = providers.some( - (p) => p.name === "builtin::local_emailpassword" - ); + const wrapperRef = useRef(null); + const [wrapperHeight, setWrapperHeight] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + if (wrapperRef.current) { + setWrapperHeight(wrapperRef.current.children[0].clientHeight); + } + }, []); + + let hasPasswordProvider = false, + hasWebAuthnProvider = false, + hasMagicLinkProvider = false; + const oauthProviders: OAuthProviderData[] = []; + for (const provider of providers) { + switch (provider._typename) { + case "ext::auth::EmailPasswordProviderConfig": + hasPasswordProvider = true; + break; + case "ext::auth::WebAuthnProviderConfig": + hasWebAuthnProvider = true; + break; + case "ext::auth::MagicLinkProviderConfig": + hasMagicLinkProvider = true; + break; + default: + oauthProviders.push(provider); + } + } + + const hasEmailFactor = + hasPasswordProvider || hasWebAuthnProvider || hasMagicLinkProvider; const oauthButtons = providers .filter((provider) => _providersInfo[provider._typename].kind === "OAuth") @@ -38,6 +73,117 @@ export function LoginUIPreview({ )); + let extraPadding = 0; + let emailFactorForm = ( + <> + + + + ); + const passwordInput = ( + <> +
+ + + Forgot password? + +
+ + + ); + if (hasWebAuthnProvider && hasMagicLinkProvider) { + const tabs = [ + { + title: "Passkey", + content: ( + <> + {emailFactorForm} + + ); +} - {hasPasswordProvider && oauthButtons.length ? ( -
- or +function Tabs({ + tabs, +}: { + tabs: { + title: string; + content: JSX.Element; + }[]; +}) { + const [selectedIndex, setSelectedIndex] = useState(0); + + return ( + <> +
+ {tabs.map(({title}, i) => ( +
setSelectedIndex(i)} + > + {title} + + +
- ) : null} + ))} +
+ + + ); +} - {hasPasswordProvider ? ( - <> - - - -
- - - Forgot password? - -
- - - - -
- Don't have an account? Sign up -
- - ) : null} - +function TabSlider({ + tabs, + selectedIndex, +}: { + tabs: {content: JSX.Element}[]; + selectedIndex: number; +}) { + const ref = useRef(null); + const [height, setHeight] = useState(null); + + useEffect(() => { + if (ref.current) { + setHeight(ref.current.children[selectedIndex].scrollHeight); + } + }, [selectedIndex]); + + return ( +
+ {tabs.map(({content}, i) => ( +
+ {content} +
+ ))}
); } diff --git a/shared/studio/tabs/auth/loginuipreview.module.scss b/shared/studio/tabs/auth/loginuipreview.module.scss index 005504df..dd70f07f 100644 --- a/shared/studio/tabs/auth/loginuipreview.module.scss +++ b/shared/studio/tabs/auth/loginuipreview.module.scss @@ -39,8 +39,11 @@ max-height: 100px; } - form { + .wrapper { grid-row: 2; + } + + form { background: #fff; padding: 24px; padding-bottom: 16px; @@ -50,6 +53,7 @@ 0px 7px 7px rgba(3, 7, 18, 0.03), 0px 16px 16px rgba(3, 7, 18, 0.05); display: flex; flex-direction: column; + overflow: hidden; } form h1 { @@ -98,20 +102,128 @@ border: none; color: var(--accent-bg-text-color); font-family: inherit; - font-size: 18px; + font-size: 17px; font-weight: 550; cursor: pointer; margin: 8px 0; + + span { + grid-column: 2; + margin: 0 12px; + } + + svg { + margin-left: 8px; + justify-self: end; + } + + &:hover { + background: var(--accent-bg-hover-color); + } + + &.secondary { + background: none; + border: 1px solid #ced4da; + color: #6c757d; + font-weight: 500; + + svg { + color: #adb5bd; + } + + &:hover { + background: #f5f6f8; + } + } + + &.iconOnly { + display: flex; + width: 46px; + padding: 0; + justify-content: center; + margin-right: 12px; + flex-shrink: 0; + + svg { + margin-left: 0; + transform: scaleX(-1); + } + } } - form button span { - grid-column: 2; + + .buttonGroup { + display: flex; + + button:not(.iconOnly) { + flex-grow: 1; + } } - form button svg { - margin-left: 8px; - justify-self: end; + + .sliderContainer { + width: calc(100% + 48px); + display: flex; + align-items: flex-start; + margin: 0 -24px; + transition: transform 0.3s, height 0.3s; } - form button:hover { - background: var(--accent-bg-hover-color); + + .sliderSection { + width: calc(100% - 48px); + margin: 0 24px; + flex-shrink: 0; + display: flex; + flex-direction: column; + height: 0; + visibility: hidden; + opacity: 0; + transition: opacity 0.15s 0s linear, visibility 0s 0.3s linear; + + & > * { + flex-shrink: 0; + } + + &.active { + height: auto; + visibility: visible; + opacity: 1; + transition-delay: 0s; + } + } + + .tabs { + display: flex; + justify-content: center; + gap: 12px; + margin-bottom: 20px; + } + .tab { + position: relative; + display: flex; + height: 38px; + align-items: center; + padding: 0 12px; + color: #6c757d; + font-size: 15px; + font-weight: 550; + cursor: pointer; + + svg { + position: absolute; + bottom: -1px; + left: 0; + width: 100%; + fill: var(--accent-text-color); + opacity: 0; + transition: opacity 0.3s; + } + + &.active { + color: #495057; + + svg { + opacity: 1; + } + } } .fieldHeader { @@ -133,25 +245,44 @@ flex-direction: column; gap: 16px; margin-bottom: 8px; - } - .oauthButtons a { - display: flex; - align-items: center; - justify-content: flex-start; - height: 46px; - padding: 0 12px; - border-radius: 8px; - border: 1px solid #dee2e6; - text-decoration: none; - color: #495057; - font-size: 16px; - font-weight: 450; - } - .oauthButtons a:hover { - background: #f5f6f8; - } - .oauthButtons a span { - margin-left: 12px; + + a { + display: flex; + align-items: center; + justify-content: flex-start; + height: 46px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid #dee2e6; + text-decoration: none; + color: #495057; + font-size: 16px; + font-weight: 450; + + &:hover { + background: #f5f6f8; + } + + span { + margin-left: 12px; + } + } + + &.collapsed { + flex-direction: row; + flex-wrap: wrap; + + a { + padding: 0; + width: 46px; + justify-content: center; + flex-shrink: 0; + + span { + display: none; + } + } + } } .divider { @@ -192,9 +323,22 @@ form { background: #2a2f34; - } - form h1 { - color: #dee2e6; + + h1 { + color: #dee2e6; + } + + button.secondary { + border-color: #495057; + color: #ced4da; + + svg { + color: #6c757d; + } + &:hover { + background: #363c42; + } + } } form textarea, @@ -231,6 +375,14 @@ border-bottom-color: #495057; } + .tab { + color: #adb5bd; + + &.active { + color: #dee2e6; + } + } + .bottomNote { color: #ced4da; } diff --git a/shared/studio/tabs/auth/state/index.tsx b/shared/studio/tabs/auth/state/index.tsx index 77429f7d..ea5896d4 100644 --- a/shared/studio/tabs/auth/state/index.tsx +++ b/shared/studio/tabs/auth/state/index.tsx @@ -49,9 +49,22 @@ export type LocalEmailPasswordProviderData = { _typename: "ext::auth::EmailPasswordProviderConfig"; require_verification: boolean; }; +export type LocalWebAuthnProviderData = { + name: string; + _typename: "ext::auth::WebAuthnProviderConfig"; + relying_party_origin: string; + require_verification: boolean; +}; +export type LocalMagicLinkProviderData = { + name: string; + _typename: "ext::auth::MagicLinkProviderConfig"; + token_time_to_live: string; +}; export type AuthProviderData = | OAuthProviderData - | LocalEmailPasswordProviderData; + | LocalEmailPasswordProviderData + | LocalWebAuthnProviderData + | LocalMagicLinkProviderData; export interface AuthUIConfigData { redirect_to: string; @@ -125,6 +138,16 @@ export const _providersInfo: { displayName: "Email + Password", icon: <>, }, + "ext::auth::WebAuthnProviderConfig": { + kind: "Local", + displayName: "WebAuthn", + icon: <>, + }, + "ext::auth::MagicLinkProviderConfig": { + kind: "Local", + displayName: "Magic link", + icon: <>, + }, }; export type ProviderTypename = keyof typeof _providersInfo; @@ -253,6 +276,11 @@ export class AuthAdminState extends Model({ brand_color, `; + const hasWebAuthn = + !!this.providersInfo["ext::auth::WebAuthnProviderConfig"]; + const hasMagicLink = + !!this.providersInfo["ext::auth::MagicLinkProviderConfig"]; + const {result} = await conn.query( `with module ext::auth select { @@ -266,7 +294,23 @@ export class AuthAdminState extends Model({ name, [is OAuthProviderConfig].client_id, [is OAuthProviderConfig].additional_scope, - [is EmailPasswordProviderConfig].require_verification, + require_verification := ( + [is EmailPasswordProviderConfig].require_verification${ + hasWebAuthn + ? ` ?? [is WebAuthnProviderConfig].require_verification` + : "" + } + ), + ${ + hasWebAuthn + ? `[is WebAuthnProviderConfig].relying_party_origin,` + : "" + } + ${ + hasMagicLink + ? `token_time_to_live_seconds := duration_get([is MagicLinkProviderConfig].token_time_to_live, 'totalseconds'),` + : "" + } }, ui: { redirect_to, @@ -303,7 +347,11 @@ export class AuthAdminState extends Model({ dark_logo_url: auth.dark_logo_url ?? auth.ui?.dark_logo_url ?? null, brand_color: auth.brand_color ?? auth.ui?.brand_color ?? null, }; - this.providers = auth.providers; + this.providers = auth.providers.map((p: any) => + p._typename === "ext::auth::MagicLinkProviderConfig" + ? {...p, token_time_to_live: p.token_time_to_live_seconds} + : p + ); this.uiConfig = auth.ui ?? false; this._createDraftCoreConfig(); if (auth.ui) { @@ -319,6 +367,20 @@ export class AuthAdminState extends Model({ } } +function validateDuration(dur: string, required: boolean) { + dur = dur.trim(); + if (!dur.length) { + return required ? `Duration is required` : null; + } + try { + if (/^\d+$/.test(dur)) return null; + parsers["std::duration"](dur, null); + } catch { + return `Invalid duration`; + } + return null; +} + export interface AbstractDraftConfig { updating: boolean; formChanged: boolean; @@ -375,17 +437,7 @@ export class DraftCoreConfig get tokenTimeToLiveError() { let dur = this._token_time_to_live; if (dur === null) return null; - dur = dur.trim(); - if (!dur.length) { - return `Duration is required`; - } - try { - if (/^\d+$/.test(dur)) return null; - parsers["std::duration"](dur, null); - } catch { - return `Invalid duration`; - } - return null; + return validateDuration(dur, true); } @computed @@ -846,7 +898,11 @@ export class DraftProviderConfig extends Model({ oauthSecret: prop("").withSetter(), additionalScope: prop("").withSetter(), + webauthnRelyingOrigin: prop("").withSetter(), + requireEmailVerification: prop(true).withSetter(), + + tokenTimeToLive: prop("").withSetter(), }) { @computed get oauthClientIdError() { @@ -858,13 +914,50 @@ export class DraftProviderConfig extends Model({ return this.oauthSecret.trim() === "" ? "Secret is required" : null; } + @computed + get webauthnRelyingOriginError() { + const origin = this.webauthnRelyingOrigin.trim(); + if (origin === "") { + return "Relying origin is required"; + } + let url: URL; + try { + url = new URL(origin); + } catch { + return "Invalid origin"; + } + if (!url.protocol || !url.host) { + return "Relying origin must contain a protocol and host"; + } + if ( + url.username || + url.password || + !(url.pathname === "" || url.pathname === "/") || + url.search || + url.hash + ) { + return "Relying origin can only contain protocol, hostname and port"; + } + return null; + } + + @computed + get tokenTimeToLiveError() { + return validateDuration(this.tokenTimeToLive, false); + } + @computed get formValid(): boolean { switch (_providersInfo[this.selectedProviderType].kind) { case "OAuth": return !this.oauthClientIdError && !this.oauthSecretError; case "Local": - return true; + return this.selectedProviderType === + "ext::auth::WebAuthnProviderConfig" + ? !this.webauthnRelyingOriginError + : this.selectedProviderType === "ext::auth::MagicLinkProviderConfig" + ? !this.tokenTimeToLiveError + : true; } } @@ -887,28 +980,56 @@ export class DraftProviderConfig extends Model({ try { const provider = _providersInfo[this.selectedProviderType]; + const queryFields: string[] = []; + if (provider.kind === "OAuth") { + queryFields.push( + `client_id := ${JSON.stringify(this.oauthClientId)}`, + `secret := ${JSON.stringify(this.oauthSecret)}` + ); + if (this.additionalScope.trim()) { + queryFields.push( + `additional_scope := ${JSON.stringify( + this.additionalScope.trim() + )}` + ); + } + } else if (provider.kind === "Local") { + if ( + this.selectedProviderType === "ext::auth::WebAuthnProviderConfig" + ) { + queryFields.push( + `relying_party_origin := ${JSON.stringify( + this.webauthnRelyingOrigin + )}` + ); + } + if ( + this.selectedProviderType === "ext::auth::WebAuthnProviderConfig" || + this.selectedProviderType === + "ext::auth::EmailPasswordProviderConfig" + ) { + queryFields.push( + `require_verification := ${ + this.requireEmailVerification ? "true" : "false" + }` + ); + } + if ( + this.selectedProviderType === "ext::auth::MagicLinkProviderConfig" && + this.tokenTimeToLive.trim() !== "" + ) { + queryFields.push( + `token_time_to_live := ${JSON.stringify( + this.tokenTimeToLive + )}` + ); + } + } + await conn.execute( `configure current database insert ${this.selectedProviderType} { - ${ - provider.kind === "OAuth" - ? ` - client_id := ${JSON.stringify(this.oauthClientId)}, - secret := ${JSON.stringify(this.oauthSecret)}, - ${ - this.additionalScope.trim() - ? `additional_scope := ${JSON.stringify( - this.additionalScope.trim() - )}` - : "" - } - ` - : provider.kind === "Local" - ? `require_verification := ${ - this.requireEmailVerification ? "true" : "false" - },` - : "" - } + ${queryFields.join(",\n")} }` ); await state.refreshConfig(); diff --git a/yarn.lock b/yarn.lock index 6f9d58fc..90468907 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5207,17 +5207,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001317": - version: 1.0.30001319 - resolution: "caniuse-lite@npm:1.0.30001319" - checksum: 1c03cc4ca019c410d197b76604cd8605077ef124906f3debd3f026568e01a1aa3888cdfcb0d23c0786115b0b3f790486f2aa8e0cce361d3dcc5c92ff3611f73e - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001400": - version: 1.0.30001448 - resolution: "caniuse-lite@npm:1.0.30001448" - checksum: 8d8bb7d097a9c80c01a4f8e3e1819cc7fc218114c8cc8773fe35aef898d12ff4e668b132a661a221ab56c022981c1df708fbe8efa09a3ce21dff97eee483cf90 +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001317, caniuse-lite@npm:^1.0.30001400": + version: 1.0.30001589 + resolution: "caniuse-lite@npm:1.0.30001589" + checksum: 7a6e6c4fb14c2bd0103a8f744bdd8701c1a5f19162f4a7600b89e25bc86d689f82204dc135f3a1dcd1a53050caa04fd0bb39b7df88698a6b90f189ec48900689 languageName: node linkType: hard