From 2352fd159fe7039e718f04b0ecddc1eea950a5df Mon Sep 17 00:00:00 2001 From: James Clarke Date: Wed, 4 Oct 2023 21:49:34 +0100 Subject: [PATCH] Add auth admin tab --- shared/studio/icons/index.tsx | 3 +- shared/studio/tabs/auth/authAdmin.module.scss | 402 +++++++++++ shared/studio/tabs/auth/colourUtils.ts | 95 +++ shared/studio/tabs/auth/icons.tsx | 156 +++++ shared/studio/tabs/auth/index.tsx | 650 ++++++++++++++++++ shared/studio/tabs/auth/loginUIPreview.tsx | 124 ++++ .../tabs/auth/loginuipreview.module.scss | 224 ++++++ shared/studio/tabs/auth/state/index.tsx | 447 ++++++++++++ web/package.json | 2 + web/src/components/databasePage/index.tsx | 2 + yarn.lock | 19 + 11 files changed, 2123 insertions(+), 1 deletion(-) create mode 100644 shared/studio/tabs/auth/authAdmin.module.scss create mode 100644 shared/studio/tabs/auth/colourUtils.ts create mode 100644 shared/studio/tabs/auth/icons.tsx create mode 100644 shared/studio/tabs/auth/index.tsx create mode 100644 shared/studio/tabs/auth/loginUIPreview.tsx create mode 100644 shared/studio/tabs/auth/loginuipreview.module.scss create mode 100644 shared/studio/tabs/auth/state/index.tsx diff --git a/shared/studio/icons/index.tsx b/shared/studio/icons/index.tsx index 2643180e..b0b44da8 100644 --- a/shared/studio/icons/index.tsx +++ b/shared/studio/icons/index.tsx @@ -383,9 +383,10 @@ export function ThemeSwitcherIcon() { ); } -export function DeleteIcon() { +export function DeleteIcon({className}: {className?: string}) { return ( div { + height: 34px; + border-radius: 6px; + text-transform: none; + font-size: 14px; + } +} + +.generateKeyButton { + position: relative; + display: flex; + width: 32px; + height: 32px; + cursor: pointer; + align-items: center; + justify-content: center; + color: #747474; + font-family: "Roboto", sans-serif; + margin-left: -8px; + margin-right: 4px; + + span { + position: absolute; + pointer-events: none; + opacity: 0; + transition: opacity 0.1s; + font-size: 12px; + white-space: nowrap; + top: 100%; + line-height: 16px; + color: #4d4d4d; + background: #fff; + padding: 4px 8px; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + z-index: 1; + } + + &:hover { + color: #4d4d4d; + + span { + opacity: 1; + } + } +} + +.configGrid { + display: grid; + grid-template-columns: auto auto; + gap: 24px; + margin: 0 16px; + + .configName { + font-family: "Roboto Mono", monospace; + text-align: right; + line-height: 34px; + } + + .configInput { + display: flex; + gap: 8px; + } + + .configExplain { + max-width: 320px; + opacity: 0.7; + font-size: 13px; + margin: 6px 4px; + } +} + +.providersList { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.noProviders { + border: 2px dashed #ccc; + padding: 18px; + border-radius: 8px; + opacity: 0.7; + font-style: italic; + width: 560px; + box-sizing: border-box; + text-align: center; +} + +.providerCard { + background: #eee; + border-radius: 8px; + display: flex; + flex-direction: column; + width: 560px; + + .providerCardHeader { + display: flex; + align-items: center; + height: 48px; + font-weight: 500; + + .oauthIcon { + width: 24px; + height: 24px; + margin-left: -8px; + margin-right: 4px; + } + } + + .expandProvider { + width: 40px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.8; + + &.collapsed svg { + transform: rotate(-90deg); + } + + &.disabled { + pointer-events: none; + + svg { + display: none; + } + } + } + + .providerType { + opacity: 0.75; + margin-left: 8px; + text-transform: uppercase; + font-size: 12px; + margin-top: 1px; + margin-bottom: -1px; + background: #d3d3d3; + padding: 2px 8px; + border-radius: 16px; + } + + .removeProviderButton { + margin-left: auto; + --buttonTextColour: #747474; + display: none; + + .icon { + fill: currentColor; + } + + & > div { + text-transform: none; + font-size: 14px; + } + + &.noHide { + display: block; + } + } + + &:hover .removeProviderButton { + display: block; + } + + .providerDetails { + display: grid; + grid-template-columns: auto auto; + align-self: flex-start; + gap: 16px 24px; + padding: 16px 18px; + padding-top: 6px; + font-family: "Roboto Mono", monospace; + } +} + +.addProviderForm { + background: #eee; + border-radius: 8px; + width: 560px; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 16px; + box-sizing: border-box; + + .errorMessage { + color: #db5246; + background-color: #db52461a; + border-radius: 6px; + padding: 8px 16px; + } +} + +.addProviderFormButtons { + display: flex; + gap: 8px; + margin-top: 16px; + + .button { + --buttonBg: #e2e2e2; + } +} + +.providerSelect { + background: #fff; + flex-grow: 1; + padding: 0px 10px; + border: 1px solid #d6d6d6; + border-radius: 6px; + + & > .providerSelectItem .oauthIcon { + margin-left: 0; + } +} + +.providerSelectItem { + display: flex; + align-items: center; + line-height: 22px; + + .oauthIcon { + width: 22px; + height: 22px; + margin-right: 6px; + margin-left: -6px; + } +} + +.uiConfigSection { + display: flex; + gap: 56px; + align-self: stretch; +} + +.uiConfigFormButtons { + display: flex; + gap: 8px; + margin-top: 24px; + + .disableButton { + margin-left: auto; + } +} + +.loginUIPreview { + flex-grow: 1; + max-width: 800px; + margin-top: -32px; +} + +.loginUIPreviewHeader { + display: flex; + align-items: center; + margin-bottom: 4px; + padding-left: 6px; + opacity: 0.9; + + span { + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + } + + .themeSwitch { + margin-left: auto; + display: flex; + align-items: center; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + svg { + width: 16px; + height: 16px; + margin-right: 4px; + opacity: 0.7; + } + } +} + +.colorPickerSwatch { + position: relative; + border: 1px solid #d6d6d6; + height: 32px; + width: 32px; + border-radius: 6px; + background-clip: padding-box; + cursor: pointer; + + .colorPickerPopup { + position: absolute; + bottom: -1px; + left: -1px; + z-index: 1; + } +} diff --git a/shared/studio/tabs/auth/colourUtils.ts b/shared/studio/tabs/auth/colourUtils.ts new file mode 100644 index 00000000..156f5238 --- /dev/null +++ b/shared/studio/tabs/auth/colourUtils.ts @@ -0,0 +1,95 @@ +export function normaliseHexColor(hex: string) { + if (hex.length === 3) { + return hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + return hex; +} + +export function getColourVariables(bgHex: string) { + const bgRGB = hexToRGB(bgHex); + const bgHSL = RGBToHSL(...bgRGB); + const luma = RGBToLuma(...bgRGB); + const lumaDark = luma < 0.6; + + return { + "--accent-bg-color": `#${bgHex}`, + "--accent-bg-text-color": `#${RGBToHex( + ...HSLToRGB( + bgHSL[0], + bgHSL[1], + lumaDark ? 95 : Math.max(10, Math.min(25, luma * 100 - 60)) + ) + )}`, + "--accent-bg-hover-color": `#${RGBToHex( + ...HSLToRGB(bgHSL[0], bgHSL[1], bgHSL[2] + (lumaDark ? 5 : -5)) + )}`, + "--accent-text-color": `#${RGBToHex( + ...HSLToRGB(bgHSL[0], bgHSL[1], Math.min(lumaDark ? 90 : 35, bgHSL[2])) + )}`, + "--accent-text-dark-color": `#${RGBToHex( + ...HSLToRGB(bgHSL[0], bgHSL[1], Math.max(60, bgHSL[2])) + )}`, + }; +} + +export function hexToRGB(hex: string): [number, number, number] { + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; +} + +export function RGBToHex(r: number, g: number, b: number): string { + return ( + r.toString(16).padStart(2, "0") + + g.toString(16).padStart(2, "0") + + b.toString(16).padStart(2, "0") + ); +} + +export function RGBToLuma(r: number, g: number, b: number): number { + return (r * 0.299 + g * 0.587 + b * 0.114) / 255; +} + +export function RGBToHSL( + r: number, + g: number, + b: number +): [number, number, number] { + r /= 255; + g /= 255; + b /= 255; + const l = Math.max(r, g, b); + const s = l - Math.min(r, g, b); + const h = s + ? l === r + ? (g - b) / s + : l === g + ? 2 + (b - r) / s + : 4 + (r - g) / s + : 0; + return [ + 60 * h < 0 ? 60 * h + 360 : 60 * h, + 100 * (s ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0), + (100 * (2 * l - s)) / 2, + ]; +} + +export function HSLToRGB( + h: number, + s: number, + l: number +): [number, number, number] { + s /= 100; + l /= 100; + const k = (n: number) => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + return [ + Math.round(255 * f(0)), + Math.round(255 * f(8)), + Math.round(255 * f(4)), + ]; +} diff --git a/shared/studio/tabs/auth/icons.tsx b/shared/studio/tabs/auth/icons.tsx new file mode 100644 index 00000000..ea4db2ec --- /dev/null +++ b/shared/studio/tabs/auth/icons.tsx @@ -0,0 +1,156 @@ +import styles from "./authAdmin.module.scss"; + +export function GenerateKeyIcon() { + return ( + + + + ); +} + +export function AppleIcon() { + return ( + + + + ); +} + +export function AzureIcon() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function GithubIcon() { + return ( + + + + ); +} + +export function GoogleIcon() { + return ( + + + + + + + ); +} diff --git a/shared/studio/tabs/auth/index.tsx b/shared/studio/tabs/auth/index.tsx new file mode 100644 index 00000000..089f0b19 --- /dev/null +++ b/shared/studio/tabs/auth/index.tsx @@ -0,0 +1,650 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react"; +import {observer} from "mobx-react-lite"; +import {HexColorPicker, HexColorInput} from "react-colorful"; + +import cn from "@edgedb/common/utils/classNames"; + +import styles from "./authAdmin.module.scss"; + +import {useInstanceState} from "../../state/instance"; +import {useDatabaseState} from "../../state/database"; +import {DatabaseTabSpec} from "../../components/databasePage"; +import {useDBRouter} from "../../hooks/dbRoute"; + +import {ChevronDownIcon, DeleteIcon, TabDashboardIcon} from "../../icons"; +import { + AuthAdminState, + AuthProviderData, + DraftProviderConfig, + ProviderTypename, + providerTypenames, + providers, + DraftUIConfig, + AuthUIConfigData, + ProviderKind, + OAuthProviderData, +} from "./state"; +import {useTabState} from "../../state"; +import {encodeB64} from "edgedb/dist/primitives/buffer"; +import Button from "@edgedb/common/ui/button"; +import {GenerateKeyIcon} from "./icons"; +import {Select, SelectItem, SelectItems} from "@edgedb/common/ui/select"; +import {LoginUIPreview} from "./loginUIPreview"; +import {normaliseHexColor} from "./colourUtils"; +import {useTheme, Theme} from "@edgedb/common/hooks/useTheme"; +import { + DarkThemeIcon, + LightThemeIcon, +} from "@edgedb/common/ui/themeSwitcher/icons"; + +export const AuthAdmin = observer(function AuthAdmin() { + const state = useTabState(AuthAdminState); + + return ( +
+ {state.extEnabled === null ? ( +
Loading schema...
+ ) : state.extEnabled ? ( +
+ {state.selectedTab === "config" ? : null} +
+ ) : ( +
auth ext disabled
+ )} +
+ ); +}); + +export const authAdminTabSpec: DatabaseTabSpec = { + path: "auth", + label: "Auth Admin", + icon: (active) => , + usesSessionState: false, + element: , + state: AuthAdminState, +}; + +const secretPlaceholder = "".padStart(32, "•"); + +const ConfigPage = observer(function ConfigPage() { + const state = useTabState(AuthAdminState); + + useEffect(() => { + state.refreshConfig(); + }, []); + + return ( +
+
Auth Configuration
+
+
auth_signing_key
+
+
+ {state.configData ? ( + state.draftSigningKey.value !== null || + !state.configData.signing_key_exists ? ( + <> + state.draftSigningKey.setValue(key)} + error={state.draftSigningKey.error} + size={32.5} + showGenerateKey + /> +
+
+ The signing key used for auth extension. Must be at least 32 + characters long. +
+
+ +
token_time_to_live
+
+
+ {state.configData ? ( + <> + state.draftTokenTime.setValue(dur)} + error={state.draftTokenTime.error} + /> + {state.draftTokenTime.value != null ? ( + <> +
+
+ The time after which an auth token expires. A value of 0 indicates + that the token should never expire. +
+
+
+ +
Providers
+ {state.providers ? ( + <> +
+ {state.providers.length ? ( + state.providers.map((provider) => ( + + )) + ) : ( +
+ No providers are configured +
+ )} +
+ {state.draftProviderConfig ? ( + + ) : state.providers.length < providerTypenames.length ? ( +
+ ); +}); + +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 login. +
+
+ +
app_name
+
+
+ draft.setConfigValue("app_name", val)} + /> +
+
+ The name of your application to be shown on the login screen. +
+
+ +
logo_url
+
+
+ draft.setConfigValue("logo_url", val)} + /> +
+
+ A url to an image of your application's logo. +
+
+ +
dark_logo_url
+
+
+ draft.setConfigValue("dark_logo_url", val)} + /> +
+
+ A url to an image of your application's logo to be used with the + dark theme. +
+
+ +
brand_color
+
+
+
+ + draft.setConfigValue("brand_color", color.slice(1)) + } + /> +
+ + draft.setConfigValue("brand_color", color.slice(1)) + } + /> +
+
+ The brand color of your application as a hex string. +
+
+
+ +
+
+
+
+
+ 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(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( + new Set(state.providers?.map((p) => p._typename)) + ), + [state.providers] + ); + const providerKind = providers[draftState.selectedProviderType].kind; + + return ( +
+
+
provider
+
+ 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. +
+
{" "} + + ) : null} +
+ + {draftState.error ? ( +
{draftState.error}
+ ) : null} + +
+
+ + ); +}); + +function ProviderCard({provider}: {provider: AuthProviderData}) { + const state = useTabState(AuthAdminState); + const [deleting, setDeleting] = useState(false); + const [expanded, setExpanded] = useState(false); + + const {displayName, icon, kind} = providers[provider._typename]; + + return ( +
+
+
setExpanded(!expanded)} + > + +
+ {icon} + {displayName} +
{kind}
+ +
+ {expanded && kind === "OAuth" ? ( +
+
client_id
+
+ {(provider as OAuthProviderData).client_id} +
+ +
secret
+
{secretPlaceholder}
+
+ ) : null} +
+ ); +} + +function Input({ + value, + onChange, + error, + showGenerateKey = false, + size, +}: { + value: string; + onChange: (val: string) => void; + error?: string | null; + showGenerateKey?: boolean; + size?: number; +}) { + return ( +
+
+ onChange(e.target.value)} + size={size} + /> + {showGenerateKey ? ( +
+ onChange( + encodeB64(crypto.getRandomValues(new Uint8Array(256))).replace( + /=*$/, + "" + ) + ) + } + > + + Generate Random Key +
+ ) : null} +
+ {error ?
{error}
: null} +
+ ); +} diff --git a/shared/studio/tabs/auth/loginUIPreview.tsx b/shared/studio/tabs/auth/loginUIPreview.tsx new file mode 100644 index 00000000..d0878944 --- /dev/null +++ b/shared/studio/tabs/auth/loginUIPreview.tsx @@ -0,0 +1,124 @@ +import "@fontsource-variable/roboto-flex"; + +import cn from "@edgedb/common/utils/classNames"; + +import { + AuthProviderData, + AuthUIConfigData, + DraftUIConfig, + providers as providersInfo, +} from "./state"; + +import styles from "./loginuipreview.module.scss"; +import {getColourVariables, normaliseHexColor} from "./colourUtils"; + +export function LoginUIPreview({ + draft, + providers, + darkTheme, +}: { + draft: DraftUIConfig; + providers: AuthProviderData[]; + darkTheme: boolean; +}) { + const appName = draft.getConfigValue("app_name"); + const brandColor = draft.getConfigValue("brand_color") || "1f8aed"; + const logoUrl = draft.getConfigValue("logo_url"); + const darkLogoUrl = draft.getConfigValue("dark_logo_url"); + + const colorVariables = getColourVariables(normaliseHexColor(brandColor)); + + const hasPasswordProvider = providers.some( + (p) => p.name === "builtin::local_emailpassword" + ); + + const oauthButtons = providers + .filter((provider) => providersInfo[provider._typename].kind === "OAuth") + .map((provider) => ( + + {providersInfo[provider._typename].icon} + + Sign in with {providersInfo[provider._typename].displayName} + + + )); + + return ( +
+ {logoUrl ? ( + + + + ) : null} +
e.preventDefault()}> +

+ {appName ? ( + <> + Sign in to {appName} + + ) : ( + Sign in + )} +

+ +
{oauthButtons}
+ + {hasPasswordProvider && oauthButtons.length ? ( +
+ or +
+ ) : null} + + {hasPasswordProvider ? ( + <> + + + +
+ + + Forgot password? + +
+ + + + +
+ Don't have an account? Sign up +
+ + ) : null} +
+
+ ); +} diff --git a/shared/studio/tabs/auth/loginuipreview.module.scss b/shared/studio/tabs/auth/loginuipreview.module.scss new file mode 100644 index 00000000..58188b77 --- /dev/null +++ b/shared/studio/tabs/auth/loginuipreview.module.scss @@ -0,0 +1,224 @@ +.previewPage { + background-color: #f3f4f6; + margin: 0; + padding: 48px; + border: 1px solid #ddd; + border-radius: 8px; + background-clip: padding-box; + display: grid; + grid-template-rows: 1fr auto; + justify-content: center; + justify-items: center; + font-family: "Roboto Flex Variable", sans-serif; + + a { + cursor: pointer; + } + + .brandLogo { + margin-bottom: 16px; + align-self: end; + } + .brandLogo img { + max-width: 300px; + max-height: 100px; + } + + form { + grid-row: 2; + background: #fff; + padding: 24px; + padding-bottom: 16px; + width: 326px; + border-radius: 16px; + box-shadow: 0px 2px 2px rgba(3, 7, 18, 0.02), + 0px 7px 7px rgba(3, 7, 18, 0.03), 0px 16px 16px rgba(3, 7, 18, 0.05); + display: flex; + flex-direction: column; + } + + form h1 { + margin: 0; + color: #495057; + font-size: 22px; + font-style: normal; + font-weight: 550; + margin-bottom: 20px; + } + form h1 span { + opacity: 0.7; + } + + form input { + border-radius: 8px; + border: 1px solid #dee2e6; + background: #f8f9fa; + line-height: 40px; + padding: 0 14px; + color: #495057; + font-family: inherit; + font-size: 16px; + font-weight: 400; + outline: none; + margin-bottom: 16px; + } + + form label { + color: #495057; + font-size: 16px; + font-weight: 450; + line-height: 18px; + margin-bottom: 8px; + } + + form button { + display: grid; + align-items: center; + grid-template-columns: 1fr auto 1fr; + padding: 0 12px; + height: 46px; + border-radius: 8px; + background: var(--accent-bg-color); + border: none; + color: var(--accent-bg-text-color); + font-family: inherit; + font-size: 18px; + font-weight: 550; + cursor: pointer; + margin: 8px 0; + } + form button span { + grid-column: 2; + } + form button svg { + margin-left: 8px; + justify-self: end; + } + form button:hover { + background: var(--accent-bg-hover-color); + } + + .fieldHeader { + display: flex; + justify-content: space-between; + } + .fieldNote { + color: #97a1ab; + font-size: 14px; + font-weight: 400; + text-decoration: none; + } + a.fieldNote:hover { + color: var(--accent-text-color); + } + + .oauthButtons { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 8px; + } + .oauthButtons a { + display: flex; + align-items: center; + justify-content: 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; + } + + .divider { + display: flex; + align-items: center; + color: #6c757d; + font-size: 16px; + font-weight: 450; + line-height: 19px; + margin-top: 12px; + margin-bottom: 16px; + } + .divider span { + margin: 0 16px; + } + .divider:before, + .divider:after { + content: ""; + height: 0; + border-bottom: 1px solid #dee2e6; + flex-grow: 1; + } + + .bottomNote { + color: #6c757d; + font-size: 16px; + font-weight: 400; + line-height: 19px; + margin-top: 4px; + } + .bottomNote a { + color: var(--accent-text-color); + text-decoration: none; + } + + &.darkTheme { + background-color: #191c1f; + + form { + background: #2a2f34; + } + form h1 { + color: #dee2e6; + } + + form input { + border-color: #495057; + background: #31373d; + color: #dee2e6; + } + + form label { + color: #dee2e6; + } + + .fieldNote { + color: #adb5bd; + } + a.fieldNote:hover { + color: var(--accent-text-dark-color); + } + + .oauthButtons a { + border-color: #495057; + color: #dee2e6; + } + .oauthButtons a:hover { + background: #363c42; + } + + .divider { + color: #6c757d; + } + .divider:before, + .divider:after { + border-bottom-color: #495057; + } + + .bottomNote { + color: #ced4da; + } + .bottomNote a { + color: var(--accent-text-dark-color); + } + } +} diff --git a/shared/studio/tabs/auth/state/index.tsx b/shared/studio/tabs/auth/state/index.tsx new file mode 100644 index 00000000..3e07e6a1 --- /dev/null +++ b/shared/studio/tabs/auth/state/index.tsx @@ -0,0 +1,447 @@ +import {Duration} from "edgedb"; +import {action, computed, observable, runInAction} from "mobx"; +import { + getParent, + Model, + model, + modelAction, + objectActions, + prop, +} from "mobx-keystone"; +import {parsers} from "../../../components/dataEditor"; +import {connCtx, dbCtx} from "../../../state"; +import {AppleIcon, AzureIcon, GithubIcon, GoogleIcon} from "../icons"; + +export interface AuthConfigData { + signing_key_exists: boolean; + token_time_to_live: Duration; +} + +export type OAuthProviderData = { + name: string; + _typename: + | "ext::auth::AppleOAuthProvider" + | "ext::auth::AzureOAuthProvider" + | "ext::auth::GitHubOAuthProvider" + | "ext::auth::GoogleOAuthProvider"; + client_id: string; +}; +export type AuthProviderData = + | OAuthProviderData + | {name: string; _typename: "ext::auth::EmailPasswordProviderConfig"}; + +export interface AuthUIConfigData { + redirect_to: string; + app_name: string | null; + logo_url: string | null; + dark_logo_url: string | null; + brand_color: string | null; +} + +export type ProviderKind = "OAuth" | "Local"; + +export const providers: { + [key in AuthProviderData["_typename"]]: { + kind: ProviderKind; + displayName: string; + icon: JSX.Element; + }; +} = { + // oauth + "ext::auth::AppleOAuthProvider": { + kind: "OAuth", + displayName: "Apple", + icon: , + }, + "ext::auth::AzureOAuthProvider": { + kind: "OAuth", + displayName: "Azure", + icon: , + }, + "ext::auth::GitHubOAuthProvider": { + kind: "OAuth", + displayName: "GitHub", + icon: , + }, + "ext::auth::GoogleOAuthProvider": { + kind: "OAuth", + displayName: "Google", + icon: , + }, + // local + "ext::auth::EmailPasswordProviderConfig": { + kind: "Local", + displayName: "Email + Password", + icon: <>, + }, +}; + +export type ProviderTypename = keyof typeof providers; + +export const providerTypenames = Object.keys(providers) as ProviderTypename[]; + +@model("AuthAdmin") +export class AuthAdminState extends Model({ + selectedTab: prop<"config">("config").withSetter(), + + draftSigningKey: createDraftAuthConfig( + "auth_signing_key", + "std::str", + (key) => + (key ?? "") === "" + ? "Signing key is required" + : (key ?? "").length < 32 + ? "Signing key too short" + : null + ), + draftTokenTime: createDraftAuthConfig( + "token_time_to_live", + "std::duration", + (dur) => { + if (dur === null) return null; + try { + parsers["std::duration"](dur, null); + return null; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + } + ), + + draftProviderConfig: prop(null), + draftUIConfig: prop(null), +}) { + @computed + get extEnabled() { + return ( + dbCtx + .get(this)! + .schemaData?.extensions.some((ext) => ext.name === "auth") ?? null + ); + } + + @modelAction + addDraftProvider() { + const existingProviders = new Set(this.providers?.map((p) => p._typename)); + this.draftProviderConfig = new DraftProviderConfig({ + selectedProviderType: providerTypenames.find( + (name) => !existingProviders.has(name) + )!, + }); + } + @modelAction + cancelDraftProvider() { + this.draftProviderConfig = null; + } + + async removeProvider(typename: string, name: string) { + const conn = connCtx.get(this)!; + + await conn.execute( + `configure current database reset ${typename} + filter .name = ${JSON.stringify(name)}` + ); + await this.refreshConfig(); + } + + @modelAction + enableUI() { + if (!this.draftUIConfig) { + this.draftUIConfig = new DraftUIConfig({}); + } + } + + @modelAction + async disableUI() { + if (this.uiConfig) { + const conn = connCtx.get(this)!; + await conn.execute( + "configure current database reset ext::auth::UIConfig" + ); + } + objectActions.set(this, "draftUIConfig", null); + + this.refreshConfig(); + } + + onAttachedToRootStore() {} + + @observable.ref + configData: AuthConfigData | null = null; + + @observable.ref + providers: AuthProviderData[] | null = null; + + @observable.ref + uiConfig: AuthUIConfigData | false | null = null; + + async refreshConfig() { + const conn = connCtx.get(this)!; + + const {result} = await conn.query( + `with module ext::auth + select cfg::Config.extensions[is AuthConfig] { + signing_key_exists := signing_key_exists(), + token_time_to_live, + providers: { + _typename := .__type__.name, + name, + [is OAuthProviderConfig].client_id, + }, + ui: { + redirect_to, + app_name, + logo_url, + dark_logo_url, + brand_color, + } + }`, + undefined, + {ignoreSessionConfig: true} + ); + + if (result === null) return; + + const data = result[0]; + + runInAction(() => { + this.configData = { + signing_key_exists: data.signing_key_exists, + token_time_to_live: data.token_time_to_live, + }; + this.providers = data.providers; + this.uiConfig = data.ui ?? false; + if (data.ui) { + this.enableUI(); + } + }); + } +} + +@model("AdminDraftUIConfig") +export class DraftUIConfig extends Model({ + _redirect_to: prop(null), + _app_name: prop(null), + _logo_url: prop(null), + _dark_logo_url: prop(null), + _brand_color: prop(null), + + showDarkTheme: prop(null).withSetter(), +}) { + getConfigValue(name: keyof AuthUIConfigData) { + return ( + this[`_${name}`] ?? + (getParent(this)?.uiConfig || null)?.[name] ?? + "" + ); + } + + @modelAction + setConfigValue(name: keyof AuthUIConfigData, val: string) { + this[`_${name}`] = val; + } + + @computed + get redirectToError() { + return !this.getConfigValue("redirect_to").length + ? `'redirect_to' url is required` + : null; + } + + @computed + get formError() { + return this.redirectToError != null; + } + + @computed + get formChanged() { + return ( + this._redirect_to != null || + this._app_name != null || + this._logo_url != null || + this._dark_logo_url != null || + this._brand_color != null + ); + } + + @modelAction + clearForm() { + this._redirect_to = null; + this._app_name = null; + this._logo_url = null; + this._dark_logo_url = null; + this._brand_color = null; + } + + @observable + updating = false; + + @observable + error: string | null = null; + + @action + async update() { + if (this.formError || !this.formChanged) return; + + const conn = connCtx.get(this)!; + const state = getParent(this)!; + + this.updating = true; + this.error = null; + + try { + await conn.execute(` + configure current database reset ext::auth::UIConfig; + configure current database + insert ext::auth::UIConfig { + redirect_to := ${JSON.stringify( + this.getConfigValue("redirect_to") + )}, + ${( + ["app_name", "logo_url", "dark_logo_url", "brand_color"] as const + ) + .map((name) => { + const val = this.getConfigValue(name); + return val ? `${name} := ${JSON.stringify(val)}` : null; + }) + .filter((l) => l) + .join(",\n")} + };`); + await state.refreshConfig(); + this.clearForm(); + } catch (e) { + runInAction( + () => (this.error = e instanceof Error ? e.message : String(e)) + ); + } finally { + runInAction(() => (this.updating = false)); + } + } +} + +@model("DraftProviderConfig") +export class DraftProviderConfig extends Model({ + selectedProviderType: prop().withSetter(), + + oauthClientId: prop("").withSetter(), + oauthSecret: prop("").withSetter(), +}) { + @computed + get oauthClientIdError() { + return this.oauthClientId.trim() === "" ? "Client ID is required" : null; + } + + @computed + get oauthSecretError() { + return this.oauthSecret.trim() === "" ? "Secret is required" : null; + } + + @computed + get formValid(): boolean { + switch (providers[this.selectedProviderType].kind) { + case "OAuth": + return !this.oauthClientIdError && !this.oauthSecretError; + case "Local": + return true; + } + } + + @observable + updating = false; + + @observable + error: string | null = null; + + @action + async addProvider() { + if (!this.formValid) return; + + const conn = connCtx.get(this)!; + const state = getParent(this)!; + + this.updating = true; + this.error = null; + + try { + const provider = providers[this.selectedProviderType]; + + console.log(`configure current database + insert ${this.selectedProviderType} { + ${ + provider.kind === "OAuth" + ? ` + client_id := ${JSON.stringify(this.oauthClientId)}, + secret := ${JSON.stringify(this.oauthSecret)} + ` + : "" + } + }`); + + await conn.execute( + `configure current database + insert ${this.selectedProviderType} { + ${ + provider.kind === "OAuth" + ? ` + client_id := ${JSON.stringify(this.oauthClientId)}, + secret := ${JSON.stringify(this.oauthSecret)} + ` + : "" + } + }` + ); + await state.refreshConfig(); + state.cancelDraftProvider(); + } catch (e) { + console.log(e); + runInAction( + () => (this.error = e instanceof Error ? e.message : String(e)) + ); + } finally { + runInAction(() => (this.updating = false)); + } + } +} + +function createDraftAuthConfig( + name: string, + type: string, + validate: (val: string | null) => string | null +) { + @model(`DraftAuthConfig/${name}`) + class DraftAuthConfig extends Model({ + value: prop(null).withSetter(), + }) { + @computed + get error() { + return validate(this.value); + } + + @observable + updating = false; + + @action + async update() { + if (this.value == null || this.error) return; + + const conn = connCtx.get(this)!; + const state = getParent(this)!; + + this.updating = true; + + try { + await conn.execute( + ` + configure current database set + ext::auth::AuthConfig::${name} := <${type}>${JSON.stringify(this.value)}` + ); + await state.refreshConfig(); + this.setValue(null); + } finally { + runInAction(() => (this.updating = false)); + } + } + } + + return prop(() => new DraftAuthConfig({})); +} diff --git a/web/package.json b/web/package.json index b6c7f9b0..4e02ca45 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "@edgedb/code-editor": "workspace:*", "@edgedb/common": "workspace:*", "@edgedb/studio": "workspace:*", + "@fontsource-variable/roboto-flex": "^5.0.8", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.2.1", @@ -20,6 +21,7 @@ "mobx-keystone": "^0.67.2", "mobx-react-lite": "^3.3.0", "react": "^17.0.0", + "react-colorful": "^5.6.1", "react-dom": "^17.0.0", "react-router-dom": "^6.3.0", "react-scripts": "5.0.0", diff --git a/web/src/components/databasePage/index.tsx b/web/src/components/databasePage/index.tsx index c4f2ee4e..d3e1d1e4 100644 --- a/web/src/components/databasePage/index.tsx +++ b/web/src/components/databasePage/index.tsx @@ -25,6 +25,7 @@ import {replTabSpec} from "@edgedb/studio/tabs/repl"; import {editorTabSpec} from "@edgedb/studio/tabs/queryEditor"; import {schemaTabSpec} from "@edgedb/studio/tabs/schema"; import {dataviewTabSpec} from "@edgedb/studio/tabs/dataview"; +import {authAdminTabSpec} from "@edgedb/studio/tabs/auth"; import {useAppState} from "src/state/providers"; import {PropsWithChildren, useState} from "react"; @@ -35,6 +36,7 @@ const tabs: DatabaseTabSpec[] = [ editorTabSpec, schemaTabSpec, dataviewTabSpec, + authAdminTabSpec, ]; export default observer(function DatabasePage() { diff --git a/yarn.lock b/yarn.lock index 50a522c1..0c559bb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2123,6 +2123,13 @@ __metadata: languageName: node linkType: hard +"@fontsource-variable/roboto-flex@npm:^5.0.8": + version: 5.0.8 + resolution: "@fontsource-variable/roboto-flex@npm:5.0.8" + checksum: df9d1216c0832a7920acad8b97477cc56e9ddcab2b433f8b9acd2a6034e63a6baaa43503ec83b0f60c33a878b3b38d75253a6419534d44848609fb1b0abfa046 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.0.1": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -12024,6 +12031,16 @@ __metadata: languageName: node linkType: hard +"react-colorful@npm:^5.6.1": + version: 5.6.1 + resolution: "react-colorful@npm:5.6.1" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: e432b7cb0df57e8f0bcdc3b012d2e93fcbcb6092c9e0f85654788d5ebfc4442536d8cc35b2418061ba3c4afb8b7788cc101c606d86a1732407921de7a9244c8d + languageName: node + linkType: hard + "react-dev-utils@npm:^12.0.0": version: 12.0.0 resolution: "react-dev-utils@npm:12.0.0" @@ -14425,6 +14442,7 @@ __metadata: "@edgedb/code-editor": "workspace:*" "@edgedb/common": "workspace:*" "@edgedb/studio": "workspace:*" + "@fontsource-variable/roboto-flex": ^5.0.8 "@testing-library/jest-dom": ^5.14.1 "@testing-library/react": ^12.0.0 "@testing-library/user-event": ^13.2.1 @@ -14441,6 +14459,7 @@ __metadata: mobx-keystone: ^0.67.2 mobx-react-lite: ^3.3.0 react: ^17.0.0 + react-colorful: ^5.6.1 react-dom: ^17.0.0 react-router-dom: ^6.3.0 react-scripts: 5.0.0