- {(
- [
- ["config", "Config"],
- ["providers", "Providers"],
- ["smtp", "SMTP"],
- ] as const
- ).map(([key, label]) => (
-
state.setSelectedTab(key)}
- >
- {label}
-
The auth extension is not enabled
@@ -130,1155 +94,3 @@ export const authAdminTabSpec: DatabaseTabSpec = {
element:
,
state: AuthAdminState,
};
-
-const secretPlaceholder = "".padStart(32, "•");
-
-const AuthUrls = observer(function AuthUrls({
- builtinUIEnabled,
-}: {
- builtinUIEnabled: boolean;
-}) {
- const instanceState = useInstanceState();
- const databaseState = useDatabaseState();
-
- const url = new URL(instanceState.serverUrl);
- url.pathname = `db/${encodeURIComponent(databaseState.name)}/ext/auth`;
-
- const baseUrl = url.toString();
-
- return (
-
-
OAuth callback endpoint:
-
-
-
Built-in UI sign in url:
-
-
Built-in UI sign up url:
-
-
-
- );
-});
-
-function CopyUrl({url}: {url: string}) {
- const [copied, setCopied] = useState(false);
-
- useEffect(() => {
- if (copied) {
- const timeout = setTimeout(() => setCopied(false), 1000);
- return () => clearTimeout(timeout);
- }
- }, [copied]);
-
- return (
-
- {url}
-
-
- );
-}
-
-const ConfigPage = observer(function ConfigPage() {
- const state = useTabState(AuthAdminState);
-
- const coreConfig = state.draftCoreConfig;
-
- return (
-
-
-
-
-
Need help integrating EdgeDB Auth into your app?
-
- Check out the{" "}
-
- auth extension docs
-
- , also here are some useful URLs:
-
-
-
-
-
Auth Configuration
-
- {state.newAppAuthSchema ? (
-
- ) : null}
-
-
-
auth_signing_key
-
-
- {coreConfig ? (
- coreConfig._auth_signing_key == null &&
- state.configData!.signing_key_exists ? (
- <>
-
- {secretPlaceholder}
-
-
-
- The signing key used for auth extension. Must be at least 32
- characters long.
-
-
-
-
-
-
token_time_to_live
-
-
- {coreConfig ? (
-
- coreConfig.setConfigValue(
- "token_time_to_live",
- dur.toUpperCase()
- )
- }
- error={coreConfig.tokenTimeToLiveError}
- />
- ) : (
- "loading..."
- )}
-
-
- The number of seconds after which an auth token expires. A value
- of 0 indicates that the token should never expire.
-
-
-
-
-
-
allowed_redirect_urls
-
-
- {coreConfig ? (
- <>
-
-
- Newline-separated list of URLs that will be checked against to
- ensure redirects are going to a trusted domain controlled by the
- application. URLs are matched based on checking if the candidate
- redirect URL is a match or a subdirectory of any of these allowed
- URLs.
-
-
-
-
-
- {coreConfig ?
: null}
-
- );
-});
-
-const AppConfigForm = observer(function AppConfigForm({
- draft,
-}: {
- draft: DraftAppConfig | null;
-}) {
- return (
- <>
-
-
app_name
-
-
- {draft ? (
- draft.setConfigValue("app_name", val)}
- />
- ) : (
- "loading..."
- )}
-
-
- The name of your application to be shown on the login screen.
-
-
-
-
-
-
logo_url
-
-
- {draft ? (
- draft.setConfigValue("logo_url", val)}
- />
- ) : (
- "loading..."
- )}
-
-
- A url to an image of your application's logo.
-
-
-
-
-
-
dark_logo_url
-
-
- {draft ? (
- draft.setConfigValue("dark_logo_url", val)}
- />
- ) : (
- "loading..."
- )}
-
-
- A url to an image of your application's logo to be used with the
- dark theme.
-
-
-
-
-
-
brand_color
-
-
- {draft ? (
- <>
-
-
- draft.setConfigValue("brand_color", color.slice(1))
- }
- />
-
-
- draft.setConfigValue("brand_color", color.slice(1))
- }
- />
- >
- ) : (
- "loading..."
- )}
-
-
- The brand color of your application as a hex string.
-
-
-
- >
- );
-});
-
-const ProvidersPage = observer(function ProvidersPage() {
- const state = useTabState(AuthAdminState);
-
- return (
-
-
Providers
- {state.providers ? (
- <>
-
- {state.providers.length ? (
- state.providers.map((provider) => (
-
- ))
- ) : (
-
- No providers are configured
-
- )}
-
- {state.draftProviderConfig ? (
-
- ) : state.providers.length < state.providerTypenames.length ? (
-
- );
-});
-
-const StickyBottomBar = observer(function StickyBottomBar({
- draft,
-}: {
- draft: AbstractDraftConfig;
-}) {
- return (
-
-
- draft.update()}
- disabled={draft.formError || !draft.formChanged || draft.updating}
- loading={draft.updating}
- />
- {draft.formChanged ? (
- draft.clearForm()}
- />
- ) : null}
-
-
- );
-});
-
-const SMTPConfigPage = observer(function SMTPConfigPage() {
- const state = useTabState(AuthAdminState);
-
- const smtp = state.draftSMTPConfig;
-
- const security = smtp.getConfigValue("security") as unknown as SMTPSecurity;
-
- return (
-
-
SMTP Configuration
-
-
-
sender
-
-
- smtp.setConfigValue("sender", val)}
- size={32}
- />
-
-
- "From" address of system emails sent for e.g. password reset,
- etc.
-
-
-
-
-
-
host
-
-
- smtp.setConfigValue("host", val)}
- placeholder="localhost"
- size={32}
- />
-
-
- Host of SMTP server to use for sending emails. If not set,
- "localhost" will be used.
-
-
-
-
-
-
port
-
-
- smtp.setConfigValue("port", val)}
- placeholder={
- security === "STARTTLSOrPlainText"
- ? "587 or 25"
- : security === "TLS"
- ? "465"
- : security === "STARTTLS"
- ? "587"
- : "25"
- }
- error={smtp.portError}
- size={10}
- />
-
-
- Port of SMTP server to use for sending emails. If not set, common
- defaults will be used depending on security: 465 for TLS, 587 for
- STARTTLS, 25 otherwise.
-
-
-
-
-
-
username
-
-
- smtp.setConfigValue("username", val)}
- size={32}
- />
-
-
- Username to login as after connected to SMTP server.
-
-
-
-
-
-
new password
-
-
- smtp.setConfigValue("password", val)}
- size={32}
- />
-
-
- Password for login after connected to SMTP server. Note: will
- replace the currently configured SMTP password (if set).
-
-
-
-
-
-
security
-
-
-
-
- Security mode of the connection to SMTP server. By default,
- initiate a STARTTLS upgrade if supported by the server, or
- fallback to PlainText.
-
-
-
-
-
-
validate_certs
-
-
-
-
-
- Determines if SMTP server certificates are validated.
-
-
-
-
-
-
timeout_per_email
-
-
-
- smtp.setConfigValue("timeout_per_email", val.toUpperCase())
- }
- error={smtp.timeoutPerEmailError}
- />
-
-
- Maximum time in seconds to send an email, including retry
- attempts.
-
-
-
-
-
-
timeout_per_attempt
-
-
-
- smtp.setConfigValue("timeout_per_attempt", val.toUpperCase())
- }
- error={smtp.timeoutPerAttemptError}
- />
-
-
- Maximum time in seconds for each SMTP request.
-
-
-
-
-
-
-
- );
-});
-
-const UIConfigForm = observer(function UIConfig({
- draft,
-}: {
- draft: DraftUIConfig;
-}) {
- const state = useTabState(AuthAdminState);
- const [_, theme] = useTheme();
-
- const [disablingUI, setDisablingUI] = useState(false);
-
- return (
-
-
-
-
-
redirect_to
-
-
- draft.setConfigValue("redirect_to", val)}
- error={draft.redirectToError}
- />
-
-
- The url to redirect to after successful sign in.
-
-
-
-
-
-
redirect_to_on_signup
-
-
-
- draft.setConfigValue("redirect_to_on_signup", val)
- }
- />
-
-
- The url to redirect to after a new user signs up. If not set,
- 'redirect_to' will be used instead.
-
-
-
-
- {draft.appConfig ?
: null}
-
-
-
-
- draft.update()}
- disabled={
- draft.formError || !draft.formChanged || draft.updating
- }
- loading={draft.updating}
- />
- {state.uiConfig && draft.formChanged ? (
- draft.clearForm()}
- />
- ) : null}
-
- {
- setDisablingUI(true);
- state.disableUI();
- }}
- loading={disablingUI}
- disabled={disablingUI}
- />
-
-
-
-
-
-
-
Preview
-
- draft.setShowDarkTheme(
- !(draft.showDarkTheme ?? theme === Theme.dark)
- )
- }
- >
- {draft.showDarkTheme ?? theme == Theme.dark ? (
- <>
- Dark theme
- >
- ) : (
- <>
- Light theme
- >
- )}
-
-
-
-
-
- );
-});
-
-function ColorPickerInput({
- color,
- onChange,
-}: {
- color: string;
- onChange: (color: string) => void;
-}) {
- const ref = useRef
(null);
-
- useEffect(() => {
- ref.current?.querySelector("input")?.addEventListener("input", (e) => {
- if ((e.target as HTMLInputElement).value === "") {
- onChange("");
- }
- });
- }, []);
-
- return (
-
-
#
-
{
- if (color) onChange("#" + normaliseHexColor(color));
- }}
- />
-
- );
-}
-
-function ColorPickerPopup({
- color,
- onChange,
-}: {
- color: string;
- onChange: (color: string) => void;
-}) {
- const ref = useRef(null);
- const [open, setOpen] = useState(false);
-
- useEffect(() => {
- const listener = (e: MouseEvent) => {
- if (!ref.current?.contains(e.target as Node)) {
- setOpen(false);
- }
- };
- window.addEventListener("click", listener, {capture: true});
- return () => {
- window.removeEventListener("click", listener, {capture: true});
- };
- }, [open]);
-
- return (
- setOpen(true)}
- style={{backgroundColor: `#${color || "1f8aed"}`}}
- >
- {open ? (
-
- ) : null}
-
- );
-}
-
-function getProviderSelectItems(
- providers: typeof _providersInfo,
- existingProviders: Set
-) {
- return {
- items: [],
- groups: Object.entries(
- Object.entries(providers).reduce<{
- [group in ProviderKind]: SelectItem[];
- }>((items, [id, provider]) => {
- if (!items[provider.kind]) {
- items[provider.kind] = [];
- }
- items[provider.kind].push({
- id: id as ProviderTypename,
- label: (
-
- {provider.icon}
- {provider.displayName}
-
- ),
- disabled: existingProviders.has(id),
- });
- return items;
- }, {} as any)
- ).map(([label, items]) => ({label, items})),
- };
-}
-
-const DraftProviderConfigForm = observer(function DraftProviderConfigForm({
- draftState,
-}: {
- draftState: DraftProviderConfig;
-}) {
- const state = useTabState(AuthAdminState);
-
- const providerItems = useMemo(
- () =>
- getProviderSelectItems(
- state.providersInfo,
- new Set(state.providers?.map((p) => p._typename))
- ),
- [state.providers]
- );
- const providerKind = _providersInfo[draftState.selectedProviderType].kind;
-
- return (
-
-
-
-
provider
-
-
-
-
- {providerKind === "OAuth" ? (
- <>
-
-
client_id
-
-
- draftState.setOauthClientId(val)}
- error={draftState.oauthClientIdError}
- />
-
-
- ID for client provided by auth provider.
-
-
-
-
-
secret
-
-
- draftState.setOauthSecret(val)}
- error={draftState.oauthSecretError}
- />
-
-
- Secret provided by auth provider.
-
-
-
-
-
additional_scope
-
-
- draftState.setAdditionalScope(val)}
- />
-
-
- Space-separated list of scopes to be included in the
- authorize request to the OAuth provider.
-
-
-
- >
- ) : providerKind === "Local" ? (
- <>
- {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.
-
-
-
- ) : 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}
-
-
- {draftState.error ? (
-
{draftState.error}
- ) : null}
-
-
- draftState.addProvider()}
- />
- state.cancelDraftProvider()}
- />
-
-
- );
-});
-
-function ProviderCard({provider}: {provider: AuthProviderData}) {
- const state = useTabState(AuthAdminState);
- const [deleting, setDeleting] = useState(false);
- const [expanded, setExpanded] = useState(false);
-
- const {displayName, icon, kind} = _providersInfo[provider._typename];
-
- return (
-
-
-
setExpanded(!expanded)}
- >
-
-
- {icon}
- {displayName}
-
{kind}
-
-
}
- leftIcon
- label={deleting ? "Removing..." : "Remove"}
- loading={deleting}
- onClick={() => {
- setDeleting(true);
- state.removeProvider(provider._typename, provider.name);
- }}
- />
-
- {expanded ? (
-
- {kind === "OAuth" ? (
- <>
-
client_id
-
- {(provider as OAuthProviderData).client_id}
-
-
-
secret
-
- {secretPlaceholder}
-
-
-
additional_scope
-
- {(provider as OAuthProviderData).additional_scope || (
- none
- )}
-
- >
- ) : kind === "Local" ? (
- <>
- {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}
-
- ) : null}
-
- );
-}
-
-function Input({
- value,
- onChange,
- error,
- showGenerateKey = false,
- size,
- placeholder,
-}: {
- value: string;
- onChange: (val: string) => void;
- error?: string | null;
- showGenerateKey?: boolean;
- size?: number;
- placeholder?: string;
-}) {
- return (
-
-
-
onChange(e.target.value)}
- size={size}
- placeholder={placeholder}
- />
- {showGenerateKey ? (
-
- onChange(
- encodeB64(crypto.getRandomValues(new Uint8Array(256))).replace(
- /=*$/,
- ""
- )
- )
- }
- >
-
- Generate Random Key
-
- ) : null}
-
- {error ?
{error}
: null}
-
- );
-}
-
-function TextArea({
- value,
- onChange,
- error,
- size,
-}: {
- value: string;
- onChange: (val: string) => void;
- error?: string | null;
- size?: number;
-}) {
- return (
-
-
-
- {error ?
{error}
: null}
-
- );
-}
diff --git a/shared/studio/tabs/auth/loginUIPreview.tsx b/shared/studio/tabs/auth/loginUIPreview.tsx
index 2d5782e3..14dff409 100644
--- a/shared/studio/tabs/auth/loginUIPreview.tsx
+++ b/shared/studio/tabs/auth/loginUIPreview.tsx
@@ -6,6 +6,7 @@ import {
AuthProviderData,
DraftAppConfig,
OAuthProviderData,
+ OpenIDProviderData,
_providersInfo,
} from "./state";
@@ -41,7 +42,7 @@ export function LoginUIPreview({
let hasPasswordProvider = false,
hasWebAuthnProvider = false,
hasMagicLinkProvider = false;
- const oauthProviders: OAuthProviderData[] = [];
+ const oauthProviders: (OAuthProviderData | OpenIDProviderData)[] = [];
for (const provider of providers) {
switch (provider._typename) {
case "ext::auth::EmailPasswordProviderConfig":
@@ -61,16 +62,22 @@ export function LoginUIPreview({
const hasEmailFactor =
hasPasswordProvider || hasWebAuthnProvider || hasMagicLinkProvider;
- const oauthButtons = providers
- .filter((provider) => _providersInfo[provider._typename].kind === "OAuth")
- .map((provider) => (
-
- {_providersInfo[provider._typename].icon}
-
- Sign in with {_providersInfo[provider._typename].displayName}
-
-
- ));
+ const oauthButtons = oauthProviders.map((provider) => (
+
+ {provider._typename === "ext::auth::OpenIDConnectProvider" &&
+ provider.logo_url ? (
+
+ ) : (
+ _providersInfo[provider._typename].icon
+ )}
+
+ Sign in with{" "}
+ {provider._typename === "ext::auth::OpenIDConnectProvider"
+ ? provider.display_name
+ : _providersInfo[provider._typename].displayName}
+
+
+ ));
let extraPadding = 0;
let emailFactorForm = (
diff --git a/shared/studio/tabs/auth/loginuipreview.module.scss b/shared/studio/tabs/auth/loginuipreview.module.scss
index a1487deb..089747d5 100644
--- a/shared/studio/tabs/auth/loginuipreview.module.scss
+++ b/shared/studio/tabs/auth/loginuipreview.module.scss
@@ -19,7 +19,7 @@
@include isMobile {
padding: 48px 32px;
- margin: 0 -32px;
+ margin: 0 -24px;
border-radius: 0;
border-left: none;
border-right: none;
@@ -267,6 +267,12 @@
span {
margin-left: 12px;
}
+
+ img {
+ width: 32px;
+ height: 32px;
+ object-fit: contain;
+ }
}
&.collapsed {
diff --git a/shared/studio/tabs/auth/providers.tsx b/shared/studio/tabs/auth/providers.tsx
new file mode 100644
index 00000000..4b756dab
--- /dev/null
+++ b/shared/studio/tabs/auth/providers.tsx
@@ -0,0 +1,647 @@
+import {useMemo, useState} from "react";
+import {observer} from "mobx-react-lite";
+
+import cn from "@edgedb/common/utils/classNames";
+
+import {useTabState} from "../../state";
+import {
+ _providersInfo,
+ AuthAdminState,
+ AuthProviderData,
+ DraftProviderConfig,
+ DraftUIConfig,
+ LocalEmailPasswordProviderData,
+ LocalMagicLinkProviderData,
+ LocalWebAuthnProviderData,
+ OAuthProviderData,
+ ProviderKind,
+ ProviderTypename,
+} from "./state";
+
+import {
+ Button,
+ Checkbox,
+ ChevronDownIcon,
+ ConfirmButton,
+ FieldHeader,
+ InfoTooltip,
+ Select,
+ SelectItem,
+ TextInput,
+} from "@edgedb/common/newui";
+
+import styles from "./authAdmin.module.scss";
+import {StickyBottomBar} from "./shared";
+import {Theme, useTheme} from "@edgedb/common/hooks/useTheme";
+import {AppConfigForm} from "./config";
+import {
+ DarkThemeIcon,
+ LightThemeIcon,
+} from "@edgedb/common/ui/themeSwitcher/icons";
+import {LoginUIPreview} from "./loginUIPreview";
+import {LoadingSkeleton} from "@edgedb/common/newui/loadingSkeleton";
+
+export const ProvidersTab = observer(function ProvidersTab() {
+ const state = useTabState(AuthAdminState);
+
+ return (
+
+
Providers
+ {state.providers ? (
+ <>
+ {state.providers.length ? (
+
+ {state.providers.map((provider) => (
+
+ ))}
+
+ ) : null}
+
+ {state.draftProviderConfig ? (
+
+
+
+ ) : state.providers.length < state.providerTypenames.length ? (
+
+ state.addDraftProvider()}>
+ Add Provider
+
+
+ ) : null}
+ >
+ ) : (
+
+
+
+
+
+ )}
+
+
Built-in Login UI
+ {state.draftUIConfig ? (
+
+ ) : state.uiConfig != null ? (
+
+ state.enableUI()}>Enable UI
+
+ ) : (
+
+ )}
+
+ );
+});
+
+function getProviderSelectItems(
+ providers: typeof _providersInfo,
+ existingProviders: Set
+) {
+ return {
+ items: [],
+ groups: Object.entries(
+ Object.entries(providers).reduce<{
+ [group in ProviderKind]: SelectItem[];
+ }>((items, [id, provider]) => {
+ if (!items[provider.kind]) {
+ items[provider.kind] = [];
+ }
+ items[provider.kind].push({
+ id: id as ProviderTypename,
+ label: (
+
+ {provider.icon}
+ {provider.displayName}
+
+ ),
+ disabled:
+ (id as ProviderTypename) !== "ext::auth::OpenIDConnectProvider" &&
+ existingProviders.has(id),
+ });
+ return items;
+ }, {} as any)
+ ).map(([label, items]) => ({label, items})),
+ };
+}
+
+const DraftProviderConfigForm = observer(function DraftProviderConfigForm({
+ draftState,
+}: {
+ draftState: DraftProviderConfig;
+}) {
+ const state = useTabState(AuthAdminState);
+
+ const providerItems = useMemo(
+ () =>
+ getProviderSelectItems(
+ state.providersInfo,
+ new Set(state.providers?.map((p) => p._typename))
+ ),
+ [state.providers]
+ );
+ const providerKind = _providersInfo[draftState.selectedProviderType].kind;
+
+ return (
+
+
+
+
+ {providerKind === "OAuth" ? (
+ <>
+ {draftState.selectedProviderType ===
+ "ext::auth::OpenIDConnectProvider" ? (
+ <>
+
+
+ Provider name
+
+ The unique identifier referenced by the{" "}
+ provider
option in the auth API's
+ >
+ }
+ />
+ >
+ }
+ value={draftState.providerName ?? ""}
+ onChange={(e) => draftState.setProviderName(e.target.value)}
+ error={draftState.providerNameError}
+ />
+
+ Provider display name
+
+ >
+ }
+ value={draftState.displayName ?? ""}
+ onChange={(e) => draftState.setDisplayName(e.target.value)}
+ error={draftState.displayNameError}
+ />
+
+
+
+
+ Issuer URL
+
+ >
+ }
+ value={draftState.issuerUrl ?? ""}
+ onChange={(e) => draftState.setIssuerUrl(e.target.value)}
+ error={draftState.issuerUrlError}
+ />
+
+ Logo URL
+
+ >
+ }
+ optional
+ value={draftState.logoUrl ?? ""}
+ onChange={(e) => draftState.setLogoUrl(e.target.value)}
+ error={draftState.logoUrlError}
+ />
+
+ >
+ ) : null}
+
+
+
+ Client ID
+
+ >
+ }
+ value={draftState.oauthClientId ?? ""}
+ onChange={(e) => draftState.setOauthClientId(e.target.value)}
+ error={draftState.oauthClientIdError}
+ />
+
+ Client secret{" "}
+
+ >
+ }
+ value={draftState.oauthSecret ?? ""}
+ onChange={(e) => draftState.setOauthSecret(e.target.value)}
+ error={draftState.oauthSecretError}
+ />
+
+
+
+ Additional scopes{" "}
+
+ >
+ }
+ optional
+ value={draftState.additionalScope}
+ onChange={(e) => draftState.setAdditionalScope(e.target.value)}
+ />
+
+ >
+ ) : providerKind === "Local" ? (
+ <>
+ {draftState.selectedProviderType ===
+ "ext::auth::WebAuthnProviderConfig" ? (
+
+
+ Relying party origin
+
+ 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.
+ >
+ }
+ />
+ >
+ }
+ value={draftState.webauthnRelyingOrigin ?? ""}
+ onChange={(e) =>
+ draftState.setWebauthnRelyingOrigin(e.target.value)
+ }
+ error={draftState.webauthnRelyingOriginError}
+ />
+
+ ) : null}
+ {draftState.selectedProviderType ===
+ "ext::auth::EmailPasswordProviderConfig" ||
+ draftState.selectedProviderType ===
+ "ext::auth::WebAuthnProviderConfig" ? (
+
+
+ Require email verification
+
+ Whether the email needs to be verified before the
+ user is allowed to sign in.
+ >
+ }
+ />
+ >
+ }
+ checked={draftState.requireEmailVerification}
+ onChange={(checked) =>
+ draftState.setRequireEmailVerification(checked)
+ }
+ />
+
+ ) : null}
+ {draftState.selectedProviderType ===
+ "ext::auth::MagicLinkProviderConfig" ? (
+
+
+ Token time to live
+
+ The time after which a magic link token expires.
+ Defaults to 10 minutes.
+ >
+ }
+ />
+ >
+ }
+ size={16}
+ optional
+ value={draftState.tokenTimeToLive}
+ onChange={(e) =>
+ draftState.setTokenTimeToLive(e.target.value.toUpperCase())
+ }
+ error={draftState.tokenTimeToLiveError}
+ />
+
+ ) : null}
+ >
+ ) : null}
+
+
+ state.cancelDraftProvider()}>Cancel
+ draftState.addProvider()}
+ disabled={!draftState.formValid}
+ loading={draftState.updating}
+ >
+ {draftState.updating ? "Adding Provider..." : "Add Provider"}
+
+
+
+ );
+});
+
+function ProviderCard({provider}: {provider: AuthProviderData}) {
+ const state = useTabState(AuthAdminState);
+ const [deleting, setDeleting] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+
+ const isOpenIdConnect =
+ provider._typename === "ext::auth::OpenIDConnectProvider";
+ const {displayName, icon, kind} = _providersInfo[provider._typename];
+
+ return (
+
+
+
+ {isOpenIdConnect && provider.logo_url ? (
+
+ ) : (
+ icon
+ )}
+
+ {isOpenIdConnect ? (
+ <>
+ {provider.display_name} {displayName}
+ >
+ ) : (
+ displayName
+ )}
+
+
{kind}
+
+
setExpanded(!expanded)}
+ >
+
+
+
+
+ {expanded ? (
+
+
+
+ {isOpenIdConnect ? (
+
+ ) : (
+
+ )}
+
+ {isOpenIdConnect ? (
+
+
+
+
+ ) : null}
+ {kind === "OAuth" ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : kind === "Local" ? (
+ <>
+ {provider.name === "builtin::local_webauthn" ? (
+
+
+
+ ) : null}
+ {provider.name === "builtin::local_emailpassword" ||
+ provider.name === "builtin::local_webauthn" ? (
+
+
+
+ ) : null}
+ {provider.name === "builtin::local_magic_link" ? (
+
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+ {
+ setDeleting(true);
+ state.removeProvider(provider._typename, provider.name);
+ }}
+ >
+ {deleting ? "Removing..." : "Remove"}
+
+
+
+ ) : null}
+
+ );
+}
+
+const UIConfigForm = observer(function UIConfig({
+ draft,
+}: {
+ draft: DraftUIConfig;
+}) {
+ const state = useTabState(AuthAdminState);
+ const [_, theme] = useTheme();
+
+ const [disablingUI, setDisablingUI] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+ draft.setConfigValue("redirect_to", e.target.value)
+ }
+ error={draft.redirectToError}
+ />
+
+
+ The url to redirect to after successful sign in.
+
+
+
+
+
+
+
+
+
+
+ draft.setConfigValue("redirect_to_on_signup", e.target.value)
+ }
+ />
+
+
+ The url to redirect to after a new user signs up. If not set,
+ 'redirect_to' will be used instead.
+
+
+
+ {draft.appConfig ?
: null}
+
+
+
+ {state.uiConfig ? (
+ {
+ setDisablingUI(true);
+ state.disableUI();
+ }}
+ loading={disablingUI}
+ style={{marginRight: "auto"}}
+ >
+ Disable UI
+
+ ) : (
+ {
+ state.disableUI();
+ }}
+ loading={disablingUI}
+ style={{marginRight: "auto"}}
+ >
+ Cancel
+
+ )}
+
+ {state.uiConfig && draft.formChanged ? (
+ draft.clearForm()}>
+ Clear Changes
+
+ ) : null}
+ draft.update()}
+ disabled={draft.formError || !draft.formChanged || draft.updating}
+ loading={draft.updating}
+ >
+ {state.uiConfig ? "Update" : "Enable UI"}
+
+
+
+
+
+
Preview
+
+ draft.setShowDarkTheme(
+ !(draft.showDarkTheme ?? theme === Theme.dark)
+ )
+ }
+ >
+ {draft.showDarkTheme ?? theme == Theme.dark ? (
+ <>
+ Dark theme
+ >
+ ) : (
+ <>
+ Light theme
+ >
+ )}
+
+
+
+
+
+ );
+});
diff --git a/shared/studio/tabs/auth/shared.tsx b/shared/studio/tabs/auth/shared.tsx
new file mode 100644
index 00000000..5df75b5c
--- /dev/null
+++ b/shared/studio/tabs/auth/shared.tsx
@@ -0,0 +1,55 @@
+import {observer} from "mobx-react-lite";
+
+import cn from "@edgedb/common/utils/classNames";
+
+import {Button} from "@edgedb/common/newui";
+
+import {AbstractDraftConfig} from "./state";
+
+import styles from "./authAdmin.module.scss";
+import {PropsWithChildren} from "react";
+import {LoadingSkeleton} from "@edgedb/common/newui/loadingSkeleton";
+
+export const secretPlaceholder = "".padStart(32, "•");
+
+export function StickyBottomBar({
+ children,
+ visible,
+}: PropsWithChildren<{visible: boolean}>) {
+ return (
+
+ );
+}
+
+export const StickyFormControls = observer(function StickyFormControls({
+ draft,
+}: {
+ draft: AbstractDraftConfig;
+}) {
+ return (
+
+ draft.clearForm()}
+ style={{marginLeft: "auto"}}
+ >
+ Clear Changes
+
+
+ draft.update()}
+ disabled={draft.formError || !draft.formChanged || draft.updating}
+ loading={draft.updating}
+ >
+ Update
+
+
+ );
+});
+
+export const InputSkeleton = () => (
+
+);
diff --git a/shared/studio/tabs/auth/smtp.tsx b/shared/studio/tabs/auth/smtp.tsx
new file mode 100644
index 00000000..68a7c4e8
--- /dev/null
+++ b/shared/studio/tabs/auth/smtp.tsx
@@ -0,0 +1,253 @@
+import {observer} from "mobx-react-lite";
+
+import {useTabState} from "../../state";
+import {AuthAdminState, smtpSecurity, SMTPSecurity} from "./state";
+
+import cn from "@edgedb/common/utils/classNames";
+
+import {Checkbox, FieldHeader, Select, TextInput} from "@edgedb/common/newui";
+
+import styles from "./authAdmin.module.scss";
+import {InputSkeleton, StickyFormControls} from "./shared";
+
+export const SMTPConfigTab = observer(function SMTPConfigTab() {
+ const state = useTabState(AuthAdminState);
+
+ const loaded = state.smtpConfig != null;
+
+ const smtp = state.draftSMTPConfig;
+
+ const security = smtp.getConfigValue("security") as unknown as SMTPSecurity;
+
+ return (
+
+
SMTP Configuration
+
+
+
+
+
+
+
+
+ {loaded ? (
+ smtp.setConfigValue("sender", e.target.value)}
+ />
+ ) : (
+
+ )}
+
+
+ "From" address of system emails sent for e.g. password reset, etc.
+
+
+
+
+
+
+
+
+
+ {loaded ? (
+ smtp.setConfigValue("host", e.target.value)}
+ placeholder="localhost"
+ />
+ ) : (
+
+ )}
+
+
+ Host of SMTP server to use for sending emails. If not set,
+ "localhost" will be used.
+
+
+
+
+
+
+
+
+
+ {loaded ? (
+ smtp.setConfigValue("port", e.target.value)}
+ placeholder={
+ security === "STARTTLSOrPlainText"
+ ? "587 or 25"
+ : security === "TLS"
+ ? "465"
+ : security === "STARTTLS"
+ ? "587"
+ : "25"
+ }
+ error={smtp.portError}
+ size={10}
+ />
+ ) : (
+
+ )}
+
+
+ Port of SMTP server to use for sending emails. If not set, common
+ defaults will be used depending on security: 465 for TLS, 587 for
+ STARTTLS, 25 otherwise.
+
+
+
+
+
+
+
+
+
+ {loaded ? (
+
+ smtp.setConfigValue("username", e.target.value)
+ }
+ />
+ ) : (
+
+ )}
+
+
+ Username to login as after connected to SMTP server.
+
+
+
+
+
+
+
+
+
+ {loaded ? (
+
+ smtp.setConfigValue("password", e.target.value)
+ }
+ />
+ ) : (
+
+ )}
+
+
+ Password for login after connected to SMTP server. Note: will
+ replace the currently configured SMTP password (if set).
+
+
+
+
+
+
+
+
+
+ {loaded ? (
+
+
+ Security mode of the connection to SMTP server. By default,
+ initiate a STARTTLS upgrade if supported by the server, or fallback
+ to PlainText.
+
+
+
+
+
+
+
+
+
+ {loaded ? (
+
+ smtp.setConfigValue("validate_certs", checked)
+ }
+ />
+ ) : (
+
+ )}
+
+
+ Determines if SMTP server certificates are validated.
+
+
+
+
+
+
+
+
+
+ {loaded ? (
+
+ smtp.setConfigValue(
+ "timeout_per_email",
+ e.target.value.toUpperCase()
+ )
+ }
+ error={smtp.timeoutPerEmailError}
+ />
+ ) : (
+
+ )}
+
+
+ Maximum time in seconds to send an email, including retry attempts.
+
+
+
+
+
+
+
+
+
+ {loaded ? (
+
+ smtp.setConfigValue(
+ "timeout_per_attempt",
+ e.target.value.toUpperCase()
+ )
+ }
+ error={smtp.timeoutPerAttemptError}
+ />
+ ) : (
+
+ )}
+
+
+ Maximum time in seconds for each SMTP request.
+
+
+
+
+
+
+ );
+});
diff --git a/shared/studio/tabs/auth/state/index.tsx b/shared/studio/tabs/auth/state/index.tsx
index ea5896d4..fc7ba4c8 100644
--- a/shared/studio/tabs/auth/state/index.tsx
+++ b/shared/studio/tabs/auth/state/index.tsx
@@ -1,11 +1,11 @@
import {action, computed, observable, runInAction} from "mobx";
import {
+ arraySet,
findParent,
getParent,
Model,
model,
modelAction,
- objectActions,
prop,
} from "mobx-keystone";
import {parsers} from "../../../components/dataEditor/parsers";
@@ -42,7 +42,13 @@ export type OAuthProviderData = {
| "ext::auth::GoogleOAuthProvider"
| "ext::auth::SlackOAuthProvider";
client_id: string;
- additional_scope: string;
+ additional_scope: string | null;
+};
+export type OpenIDProviderData = Omit & {
+ _typename: "ext::auth::OpenIDConnectProvider";
+ display_name: string;
+ issuer_url: string;
+ logo_url: string | null;
};
export type LocalEmailPasswordProviderData = {
name: string;
@@ -62,6 +68,7 @@ export type LocalMagicLinkProviderData = {
};
export type AuthProviderData =
| OAuthProviderData
+ | OpenIDProviderData
| LocalEmailPasswordProviderData
| LocalWebAuthnProviderData
| LocalMagicLinkProviderData;
@@ -92,6 +99,23 @@ export interface SMTPConfigData {
timeout_per_attempt: string;
}
+export const webhookEvents = [
+ "IdentityCreated",
+ "IdentityAuthenticated",
+ "EmailFactorCreated",
+ "EmailVerified",
+ "PasswordResetRequested",
+ "MagicLinkRequested",
+] as const;
+
+export type WebhookEvent = (typeof webhookEvents)[number];
+
+export interface WebhookConfigData {
+ url: string;
+ events: [WebhookEvent, ...WebhookEvent[]];
+ signing_secret_key_exists: boolean;
+}
+
export type ProviderKind = "OAuth" | "Local";
export const _providersInfo: {
@@ -132,6 +156,11 @@ export const _providersInfo: {
displayName: "Slack",
icon: ,
},
+ "ext::auth::OpenIDConnectProvider": {
+ kind: "OAuth",
+ displayName: "OpenID Connect",
+ icon: <>>,
+ },
// local
"ext::auth::EmailPasswordProviderConfig": {
kind: "Local",
@@ -154,10 +183,13 @@ export type ProviderTypename = keyof typeof _providersInfo;
@model("AuthAdmin")
export class AuthAdminState extends Model({
- selectedTab: prop<"config" | "providers" | "smtp">("config").withSetter(),
+ selectedTab: prop<"config" | "providers" | "webhooks" | "smtp">(
+ "config"
+ ).withSetter(),
draftCoreConfig: prop(null),
draftProviderConfig: prop(null),
+ draftWebhookConfig: prop(null),
draftUIConfig: prop(null),
draftSMTPConfig: prop(() => new DraftSMTPConfig({})),
}) {
@@ -177,6 +209,15 @@ export class AuthAdminState extends Model({
);
}
+ @computed
+ get hasWebhooksSchema() {
+ return (
+ dbCtx
+ .get(this)!
+ .schemaData?.objectsByName.has("ext::auth::WebhookConfig") === true
+ );
+ }
+
@computed
get providersInfo() {
const objects = dbCtx.get(this)!.schemaData?.objectsByName;
@@ -220,6 +261,25 @@ export class AuthAdminState extends Model({
await this.refreshConfig();
}
+ @modelAction
+ addDraftWebhook() {
+ this.draftWebhookConfig = new DraftWebhookConfig({});
+ }
+ @modelAction
+ cancelDraftWebhook() {
+ this.draftWebhookConfig = null;
+ }
+
+ async removeWebhook(webhookUrl: string) {
+ const conn = connCtx.get(this)!;
+
+ await conn.execute(
+ `configure current database reset ext::auth::WebhookConfig
+ filter .url = ${JSON.stringify(webhookUrl)}`
+ );
+ await this.refreshConfig();
+ }
+
@modelAction
_createDraftCoreConfig() {
if (!this.draftCoreConfig) {
@@ -238,7 +298,6 @@ export class AuthAdminState extends Model({
}
}
- @modelAction
async disableUI() {
if (this.uiConfig) {
const conn = connCtx.get(this)!;
@@ -246,11 +305,15 @@ export class AuthAdminState extends Model({
"configure current database reset ext::auth::UIConfig"
);
}
- objectActions.set(this, "draftUIConfig", null);
this.refreshConfig();
}
+ @modelAction
+ _removeDraftUIConfig() {
+ this.draftUIConfig = null;
+ }
+
onAttachedToRootStore() {}
@observable.ref
@@ -265,6 +328,14 @@ export class AuthAdminState extends Model({
@observable.ref
smtpConfig: SMTPConfigData | null = null;
+ @observable.ref
+ webhooks: WebhookConfigData[] | null = null;
+
+ @computed
+ get webhookUrls() {
+ return new Set(this.webhooks?.map((webhook) => webhook.url));
+ }
+
async refreshConfig() {
const conn = connCtx.get(this)!;
const {newAppAuthSchema} = this;
@@ -280,6 +351,8 @@ export class AuthAdminState extends Model({
!!this.providersInfo["ext::auth::WebAuthnProviderConfig"];
const hasMagicLink =
!!this.providersInfo["ext::auth::MagicLinkProviderConfig"];
+ const hasOpenIDConnect =
+ !!this.providersInfo["ext::auth::OpenIDConnectProvider"];
const {result} = await conn.query(
`with module ext::auth
@@ -294,6 +367,13 @@ export class AuthAdminState extends Model({
name,
[is OAuthProviderConfig].client_id,
[is OAuthProviderConfig].additional_scope,
+ ${
+ hasOpenIDConnect
+ ? `[is OpenIDConnectProvider].display_name,
+ [is OpenIDConnectProvider].issuer_url,
+ [is OpenIDConnectProvider].logo_url,`
+ : ""
+ }
require_verification := (
[is EmailPasswordProviderConfig].require_verification${
hasWebAuthn
@@ -316,6 +396,18 @@ export class AuthAdminState extends Model({
redirect_to,
redirect_to_on_signup,
${newAppAuthSchema ? "" : appConfigQuery}
+ },
+ ${
+ this.hasWebhooksSchema
+ ? `webhooks := (
+ with webhook := .webhooks
+ select webhook {
+ url,
+ events,
+ signing_secret_key_exists := webhook_signing_key_exists(webhook),
+ }
+ )`
+ : ""
}
}),
smtp := assert_single(cfg::Config.extensions[is SMTPConfig] {
@@ -347,15 +439,25 @@ 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.map((p: any) =>
- p._typename === "ext::auth::MagicLinkProviderConfig"
- ? {...p, token_time_to_live: p.token_time_to_live_seconds}
- : p
- );
+ this.providers = (
+ auth.providers.map((p: any) =>
+ p._typename === "ext::auth::MagicLinkProviderConfig"
+ ? {...p, token_time_to_live: p.token_time_to_live_seconds}
+ : p
+ ) as AuthProviderData[]
+ ).sort((a, b) => {
+ const aKind = _providersInfo[a._typename].kind;
+ const bKind = _providersInfo[b._typename].kind;
+ return aKind == bKind
+ ? a.name.localeCompare(b.name)
+ : bKind.localeCompare(aKind);
+ });
this.uiConfig = auth.ui ?? false;
this._createDraftCoreConfig();
if (auth.ui) {
this.enableUI();
+ } else {
+ this._removeDraftUIConfig();
}
this.smtpConfig = {
...smtp,
@@ -363,6 +465,7 @@ export class AuthAdminState extends Model({
timeout_per_email: smtp.timeout_per_email_seconds,
timeout_per_attempt: smtp.timeout_per_attempt_seconds,
};
+ this.webhooks = auth.webhooks ?? [];
});
}
}
@@ -460,7 +563,7 @@ export class DraftCoreConfig
});
if (invalidUrls.length > 0) {
- return `List contained the following invalid URLs:\n${invalidUrls.join(
+ return `List containes the following invalid URLs:\n${invalidUrls.join(
",\n"
)}`;
}
@@ -713,6 +816,7 @@ export class DraftUIConfig extends Model({
await state.refreshConfig();
this.clearForm();
} catch (e) {
+ console.error(e);
runInAction(
() => (this.error = e instanceof Error ? e.message : String(e))
);
@@ -894,28 +998,107 @@ export class DraftSMTPConfig
export class DraftProviderConfig extends Model({
selectedProviderType: prop().withSetter(),
- oauthClientId: prop("").withSetter(),
- oauthSecret: prop("").withSetter(),
+ oauthClientId: prop(null).withSetter(),
+ oauthSecret: prop(null).withSetter(),
additionalScope: prop("").withSetter(),
- webauthnRelyingOrigin: prop("").withSetter(),
+ providerName: prop(null).withSetter(),
+ displayName: prop(null).withSetter(),
+ issuerUrl: prop(null).withSetter(),
+ logoUrl: prop("").withSetter(),
+
+ webauthnRelyingOrigin: prop(null).withSetter(),
requireEmailVerification: prop(true).withSetter(),
tokenTimeToLive: prop("").withSetter(),
}) {
@computed
- get oauthClientIdError() {
- return this.oauthClientId.trim() === "" ? "Client ID is required" : null;
+ get oauthClientIdError(): string | null {
+ if (this.oauthClientId == null) return null;
+ if (this.oauthClientId.trim() === "") {
+ return "Client ID is required";
+ }
+ return this.selectedProviderType === "ext::auth::OpenIDConnectProvider"
+ ? this._getIssuerClientIdError()
+ : null;
}
@computed
- get oauthSecretError() {
+ get oauthSecretError(): string | null {
+ if (this.oauthSecret === null) return null;
return this.oauthSecret.trim() === "" ? "Secret is required" : null;
}
@computed
- get webauthnRelyingOriginError() {
+ get providerNameError(): string | null {
+ if (this.providerName === null) return null;
+ if (this.providerName.trim() === "") {
+ return "Provider name is required";
+ }
+ if (this.providerName.startsWith("builtin::")) {
+ return "Provider name cannot start with 'builtin::'";
+ }
+ const providerNames = getParent(this)?.providers?.map(
+ (p) => p.name
+ );
+ if (providerNames?.includes(this.providerName.trim())) {
+ return "A provider with this name already exists";
+ }
+ return null;
+ }
+
+ @computed
+ get displayNameError(): string | null {
+ if (this.displayName === null) return null;
+ return this.displayName.trim() === ""
+ ? "Provider display name is required"
+ : null;
+ }
+
+ _getIssuerClientIdError(): string | null {
+ const issuerUrl = this.issuerUrl?.trim();
+ const clientId = this.oauthClientId?.trim();
+ return getParent(this)?.providers?.some(
+ (p) =>
+ p._typename === "ext::auth::OpenIDConnectProvider" &&
+ p.issuer_url === issuerUrl &&
+ p.client_id === clientId
+ )
+ ? "A provider with this Issuer URL and Client ID pair already exists"
+ : null;
+ }
+
+ @computed
+ get issuerUrlError(): string | null {
+ if (this.issuerUrl === null) return null;
+ if (this.issuerUrl.trim() === "") {
+ return "Issuer URL is required";
+ }
+ try {
+ new URL(this.issuerUrl.trim());
+ } catch {
+ return "Invalid URL";
+ }
+ return this._getIssuerClientIdError();
+ }
+
+ @computed
+ get logoUrlError(): string | null {
+ if (this.logoUrl.trim() === "") {
+ return null;
+ }
+ try {
+ new URL(this.logoUrl.trim());
+ } catch {
+ return "Invalid URL";
+ }
+ return null;
+ }
+
+ @computed
+ get webauthnRelyingOriginError(): string | null {
+ if (this.webauthnRelyingOrigin == null) return null;
const origin = this.webauthnRelyingOrigin.trim();
if (origin === "") {
return "Relying origin is required";
@@ -942,7 +1125,7 @@ export class DraftProviderConfig extends Model({
}
@computed
- get tokenTimeToLiveError() {
+ get tokenTimeToLiveError(): string | null {
return validateDuration(this.tokenTimeToLive, false);
}
@@ -950,11 +1133,26 @@ export class DraftProviderConfig extends Model({
get formValid(): boolean {
switch (_providersInfo[this.selectedProviderType].kind) {
case "OAuth":
- return !this.oauthClientIdError && !this.oauthSecretError;
+ return (
+ this.oauthClientId != null &&
+ !this.oauthClientIdError &&
+ this.oauthSecret != null &&
+ !this.oauthSecretError &&
+ (this.selectedProviderType === "ext::auth::OpenIDConnectProvider"
+ ? this.providerName != null &&
+ !this.providerNameError &&
+ this.displayName != null &&
+ !this.displayNameError &&
+ this.issuerUrl != null &&
+ !this.issuerUrlError &&
+ !this.logoUrlError
+ : true)
+ );
case "Local":
return this.selectedProviderType ===
"ext::auth::WebAuthnProviderConfig"
- ? !this.webauthnRelyingOriginError
+ ? this.webauthnRelyingOrigin != null &&
+ !this.webauthnRelyingOriginError
: this.selectedProviderType === "ext::auth::MagicLinkProviderConfig"
? !this.tokenTimeToLiveError
: true;
@@ -982,8 +1180,20 @@ export class DraftProviderConfig extends Model({
const queryFields: string[] = [];
if (provider.kind === "OAuth") {
+ if (this.selectedProviderType === "ext::auth::OpenIDConnectProvider") {
+ queryFields.push(
+ `name := ${JSON.stringify(this.providerName!.trim())}`,
+ `display_name := ${JSON.stringify(this.displayName!.trim())}`,
+ `issuer_url := ${JSON.stringify(this.issuerUrl!.trim())}`
+ );
+ if (this.logoUrl.trim()) {
+ queryFields.push(
+ `logo_url := ${JSON.stringify(this.logoUrl!.trim())}`
+ );
+ }
+ }
queryFields.push(
- `client_id := ${JSON.stringify(this.oauthClientId)}`,
+ `client_id := ${JSON.stringify(this.oauthClientId!.trim())}`,
`secret := ${JSON.stringify(this.oauthSecret)}`
);
if (this.additionalScope.trim()) {
@@ -1044,3 +1254,81 @@ export class DraftProviderConfig extends Model({
}
}
}
+
+@model("AuthAdmin/DraftWebhookConfig")
+export class DraftWebhookConfig extends Model({
+ url: prop(null).withSetter(),
+ events: prop(() => arraySet()),
+ signing_key: prop(null).withSetter(),
+}) {
+ @computed
+ get urlError() {
+ if (this.url == null) return null;
+ if (this.url.trim() === "") return "Webhook URL is required";
+ try {
+ new URL(this.url);
+ } catch {
+ return "URL is invalid";
+ }
+ return getParent(this)?.webhookUrls.has(this.url)
+ ? "A webhook already exists with this URL"
+ : null;
+ }
+
+ @computed
+ get eventsError() {
+ return this.events.size < 1
+ ? "At least one webhook event is required"
+ : null;
+ }
+
+ @computed
+ get formValid(): boolean {
+ return (
+ this.url != null && this.urlError == null && this.eventsError == null
+ );
+ }
+
+ @observable
+ updating = false;
+
+ @observable
+ error: string | null = null;
+
+ @action
+ async addWebhook() {
+ if (!this.formValid) return;
+
+ const conn = connCtx.get(this)!;
+ const state = getParent(this)!;
+
+ this.updating = true;
+ this.error = null;
+
+ try {
+ await conn.execute(
+ `configure current database
+ insert ext::auth::WebhookConfig {
+ url := ${JSON.stringify(this.url)},
+ events := {${[...this.events.values()]
+ .map((val) => `"${val}"`)
+ .join(", ")}},
+ ${
+ this.signing_key
+ ? `signing_secret_key := ${JSON.stringify(this.signing_key)}`
+ : ""
+ }
+ }`
+ );
+ await state.refreshConfig();
+ state.cancelDraftWebhook();
+ } catch (e) {
+ console.log(e);
+ runInAction(
+ () => (this.error = e instanceof Error ? e.message : String(e))
+ );
+ } finally {
+ runInAction(() => (this.updating = false));
+ }
+ }
+}
diff --git a/shared/studio/tabs/auth/webhooks.tsx b/shared/studio/tabs/auth/webhooks.tsx
new file mode 100644
index 00000000..388cc676
--- /dev/null
+++ b/shared/studio/tabs/auth/webhooks.tsx
@@ -0,0 +1,196 @@
+import {observer} from "mobx-react-lite";
+
+import cn from "@edgedb/common/utils/classNames";
+import {
+ Button,
+ Checkbox,
+ ChevronDownIcon,
+ ConfirmButton,
+ FieldHeader,
+ InfoTooltip,
+ TextInput,
+} from "@edgedb/common/newui";
+
+import {useTabState} from "../../state";
+import {
+ AuthAdminState,
+ DraftWebhookConfig,
+ WebhookConfigData,
+ webhookEvents,
+} from "./state";
+
+import styles from "./authAdmin.module.scss";
+import {useState} from "react";
+import {LoadingSkeleton} from "@edgedb/common/newui/loadingSkeleton";
+
+export const WebhooksTab = observer(function WebhooksTab() {
+ const state = useTabState(AuthAdminState);
+
+ return (
+
+
Webhooks
+ {state.webhooks ? (
+ <>
+ {state.webhooks.length ? (
+
+ {state.webhooks.map((webhook) => (
+
+ ))}
+
+ ) : null}
+
+
+ {state.draftWebhookConfig ? (
+
+ ) : (
+ state.addDraftWebhook()}>
+ Add Webhook
+
+ )}
+
+ >
+ ) : (
+
+
+
+
+
+ )}
+
+ );
+});
+
+function WebhookConfigCard({config}: {config: WebhookConfigData}) {
+ const state = useTabState(AuthAdminState);
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+
+
+
+
{config.url}
+
+ {config.events.map((event) => (
+ {event}
+ ))}
+
+
+
setExpanded(!expanded)}
+ >
+
+
+
+ {expanded ? (
+
+
+
+
+
+
+
+
+ {webhookEvents.map((event) => (
+
+ ))}
+
+
+
+
+ state.removeWebhook(config.url)}>
+ Remove
+
+
+
+ ) : null}
+
+ );
+}
+
+const WebhookDraftForm = observer(function WebhookDraftForm({
+ draft,
+}: {
+ draft: DraftWebhookConfig;
+}) {
+ const state = useTabState(AuthAdminState);
+
+ return (
+
+
+ draft.setUrl(e.target.value)}
+ error={draft.urlError}
+ />{" "}
+
+ Signing secret key{" "}
+
+ >
+ }
+ optional
+ value={draft.signing_key ?? ""}
+ onChange={(e) => draft.setSigning_key(e.target.value)}
+ />
+
+
+
+
+
+ {webhookEvents.map((event) => (
+ {
+ if (checked) {
+ draft.events.add(event);
+ } else {
+ draft.events.delete(event);
+ }
+ }}
+ />
+ ))}
+
+
+
+
+ state.cancelDraftWebhook()}>Cancel
+ draft.addWebhook()}
+ disabled={!draft.formValid}
+ loading={draft.updating}
+ >
+ {draft.updating ? "Adding Webhook..." : "Add Webhook"}
+
+
+
+ );
+});
diff --git a/web/src/app.module.scss b/web/src/app.module.scss
index 3b917ba0..1f1570e8 100644
--- a/web/src/app.module.scss
+++ b/web/src/app.module.scss
@@ -12,7 +12,7 @@
user-select: none;
font-size: 14px;
line-height: 16px;
- background-color: var(--app-bg);
+ background-color: var(--page_background);
color: var(--app-text-colour);
}
diff --git a/web/src/app.tsx b/web/src/app.tsx
index d6c7488d..211c2843 100644
--- a/web/src/app.tsx
+++ b/web/src/app.tsx
@@ -3,6 +3,7 @@ import {BrowserRouter, Route, Routes} from "react-router-dom";
import "./fonts/include.scss";
import styles from "./app.module.scss";
+import themeStyles from "@edgedb/common/newui/theme.module.scss";
import "@fontsource-variable/roboto-flex/index.css";
import "@fontsource-variable/roboto-mono/index.css";
@@ -39,7 +40,7 @@ function App() {
const AppMain = observer(function _AppMain() {
return (
-