diff --git a/package-lock.json b/package-lock.json index af05c812..057b2cc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@apollo/client": "^4.0.9", "@code0-tech/pictor": "^0.14.0", - "@code0-tech/triangulum": "^0.28.0", + "@code0-tech/triangulum": "^0.29.0", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lint": "^6.9.5", "@icons-pack/react-simple-icons": "^13.13.0", @@ -1946,17 +1946,17 @@ } }, "node_modules/@code0-tech/sagittarius-graphql-types": { - "version": "0.0.0-experimental-2705887128-b8e126d1674035ed0f869775b533d7093e8aedbd", - "resolved": "https://registry.npmjs.org/@code0-tech/sagittarius-graphql-types/-/sagittarius-graphql-types-0.0.0-experimental-2705887128-b8e126d1674035ed0f869775b533d7093e8aedbd.tgz", - "integrity": "sha512-VfX+SJNTlFbbFkM7C9s5vnHqIt+8MIFKA7QHIcF6k+rpUpv+/MTog96WEU6TrVc0Ma9r250Ybar8xdkTb5dvMQ==", + "version": "0.0.0-experimental-2722865546-50e0e86411a3ab53d9fdd8be7ec7af5c3ab43f8a", + "resolved": "https://registry.npmjs.org/@code0-tech/sagittarius-graphql-types/-/sagittarius-graphql-types-0.0.0-experimental-2722865546-50e0e86411a3ab53d9fdd8be7ec7af5c3ab43f8a.tgz", + "integrity": "sha512-SDNAiVutMgc36fFD5o4mV0GtR3EpLscwystL9SkT+XatMBHHbmwnZu/Kc9vo9EE/ZCHUGkw6XqTZjGNf+S4wNw==", "peer": true }, "node_modules/@code0-tech/triangulum": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@code0-tech/triangulum/-/triangulum-0.28.0.tgz", - "integrity": "sha512-cr90thwQ0w9o5O69Kvo3JTES9YTW6KUn5XJOleyim5VFTt0//K7yO6dxAVS8+O9Lrx+CxRDfxph7Dxdg1l1g1w==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@code0-tech/triangulum/-/triangulum-0.29.0.tgz", + "integrity": "sha512-zcsmWpqPyTwsiQmQTzgUy9iqxhG4FQnl88eipdg6mXPwnldkURlMeryP5Q4O4Tf1ZLresZ96BDHJyRfXTYKMIw==", "peerDependencies": { - "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2705887128-b8e126d1674035ed0f869775b533d7093e8aedbd", + "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2722865546-50e0e86411a3ab53d9fdd8be7ec7af5c3ab43f8a", "@typescript/vfs": "^1.6.4", "lossless-json": "^4.3.0", "typescript": "^5.9.3 || ^6.0.2" diff --git a/package.json b/package.json index 1954de13..ff02c965 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dependencies": { "@apollo/client": "^4.0.9", "@code0-tech/pictor": "^0.14.0", - "@code0-tech/triangulum": "^0.28.0", + "@code0-tech/triangulum": "^0.29.0", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lint": "^6.9.5", "@icons-pack/react-simple-icons": "^13.13.0", diff --git a/src/app/(auth)/callback/page.tsx b/src/app/(auth)/callback/page.tsx new file mode 100644 index 00000000..b48b7e8b --- /dev/null +++ b/src/app/(auth)/callback/page.tsx @@ -0,0 +1,5 @@ +"use client" + +import {UserCallbackPage} from "@edition/user/pages/UserCallbackPage"; + +export default UserCallbackPage diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index bdeeb233..0dbe79a1 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -14,6 +14,7 @@ import { Text } from "@code0-tech/pictor"; import {UserService} from "@edition/user/services/User.service"; +import {Application, ApplicationService} from "@edition/application/services/Application.service"; import {useApolloClient} from "@apollo/client/react"; import {GraphqlClient} from "@core/util/graphql-client"; import Image from "next/image"; @@ -33,13 +34,14 @@ export default function AuthLayout({children}: Readonly<{ children: React.ReactN const [store, service] = usePersistentReactiveArrayService("auth-users", (store) => new UserService(graphqlClient, store)) const organization = usePersistentReactiveArrayService(`dashboard::organizations::${currentSession?.id}`, (store) => new OrganizationService(graphqlClient, store)) + const application = usePersistentReactiveArrayService(`auth::application::${currentSession?.id}`, (store) => new ApplicationService(graphqlClient, store)) return ( - + diff --git a/src/app/(dashboard)/@modal/(.)settings/identity-providers/[identityProviderId]/settings/page.tsx b/src/app/(dashboard)/@modal/(.)settings/identity-providers/[identityProviderId]/settings/page.tsx new file mode 100644 index 00000000..e9782515 --- /dev/null +++ b/src/app/(dashboard)/@modal/(.)settings/identity-providers/[identityProviderId]/settings/page.tsx @@ -0,0 +1,5 @@ +"use client" + +import {IdentityProviderSettingsPage} from "@edition/application/pages/IdentityProviderSettingsPage"; + +export default IdentityProviderSettingsPage diff --git a/src/app/(dashboard)/@modal/(.)settings/identity-providers/create/page.tsx b/src/app/(dashboard)/@modal/(.)settings/identity-providers/create/page.tsx new file mode 100644 index 00000000..ce7caa9e --- /dev/null +++ b/src/app/(dashboard)/@modal/(.)settings/identity-providers/create/page.tsx @@ -0,0 +1,5 @@ +"use client" + +import {IdentityProviderCreatePage} from "@edition/application/pages/IdentityProviderCreatePage"; + +export default IdentityProviderCreatePage diff --git a/src/app/(dashboard)/settings/identity-providers/[identityProviderId]/settings/page.tsx b/src/app/(dashboard)/settings/identity-providers/[identityProviderId]/settings/page.tsx new file mode 100644 index 00000000..e9782515 --- /dev/null +++ b/src/app/(dashboard)/settings/identity-providers/[identityProviderId]/settings/page.tsx @@ -0,0 +1,5 @@ +"use client" + +import {IdentityProviderSettingsPage} from "@edition/application/pages/IdentityProviderSettingsPage"; + +export default IdentityProviderSettingsPage diff --git a/src/app/(dashboard)/settings/identity-providers/create/page.tsx b/src/app/(dashboard)/settings/identity-providers/create/page.tsx new file mode 100644 index 00000000..ce7caa9e --- /dev/null +++ b/src/app/(dashboard)/settings/identity-providers/create/page.tsx @@ -0,0 +1,5 @@ +"use client" + +import {IdentityProviderCreatePage} from "@edition/application/pages/IdentityProviderCreatePage"; + +export default IdentityProviderCreatePage diff --git a/src/packages/ce/src/application/components/ApplicationSettingsDialogComponent.tsx b/src/packages/ce/src/application/components/ApplicationSettingsDialogComponent.tsx index db6fc2ed..8cf4f980 100644 --- a/src/packages/ce/src/application/components/ApplicationSettingsDialogComponent.tsx +++ b/src/packages/ce/src/application/components/ApplicationSettingsDialogComponent.tsx @@ -3,11 +3,12 @@ import React from "react"; import {Button, Text} from "@code0-tech/pictor"; import {TabList, TabTrigger} from "@code0-tech/pictor/dist/components/tab/Tab"; -import {IconServer, IconSettings2, IconShieldLock, IconUsers} from "@tabler/icons-react"; +import {IconKey, IconServer, IconSettings2, IconShieldLock, IconUsers} from "@tabler/icons-react"; import {ApplicationUsersView} from "@edition/application/views/ApplicationUsersView"; import {ApplicationServersView} from "@edition/application/views/ApplicationServersView"; import {ApplicationGeneralSettingsView} from "@edition/application/views/ApplicationGeneralSettingsView"; import {ApplicationRestrictionsView} from "@edition/application/views/ApplicationRestrictionsView"; +import {ApplicationIdentityProvidersView} from "@edition/application/views/ApplicationIdentityProvidersView"; import {ApplicationLicensesView} from "@edition/application/views/ApplicationLicensesView"; import {ApplicationLicensesTabTriggerView} from "@edition/application/views/ApplicationLicensesTabTriggerView"; import {SettingDialog} from "@core/components/SettingDialog"; @@ -49,12 +50,19 @@ export const ApplicationSettingsDialogComponent: React.FCRestrictions + + + }> + } diff --git a/src/packages/ce/src/application/components/IdentityProviderSettingDialogComponent.tsx b/src/packages/ce/src/application/components/IdentityProviderSettingDialogComponent.tsx new file mode 100644 index 00000000..2f4a4cc9 --- /dev/null +++ b/src/packages/ce/src/application/components/IdentityProviderSettingDialogComponent.tsx @@ -0,0 +1,288 @@ +"use client" + +import React, {startTransition} from "react"; +import { + Button, + Flex, + InputDescription, + InputLabel, + SelectContent, + SelectInput, + SelectItem, + SelectItemText, + SelectPortal, + SelectTrigger, + SelectValue, + SelectViewport, + Spacing, + Text, + TextInput, + toast, + useForm, + useService, + useStore +} from "@code0-tech/pictor"; +import { + IconBrandDiscord, + IconBrandGithub, + IconBrandGitlab, + IconBrandGoogle, + IconBrandWindows, + IconChevronDown, + IconFingerprint, + IconShieldLock +} from "@tabler/icons-react"; +import type { + IdentityProvider, + IdentityProviderConfigInput, + IdentityProviderInput, + IdentityProviderType, + OidcIdentityProviderConfig, + SamlIdentityProviderConfig +} from "@code0-tech/sagittarius-graphql-types"; +import {InputDialog} from "@core/components/InputDialog"; +import {ApplicationService} from "@edition/application/services/Application.service"; + +export interface IdentityProviderSettingDialogComponentProps { + open?: boolean + onOpenChange?: (open: boolean) => void + identityProviderId?: string +} + +export const IdentityProviderSettingDialogComponent: React.FC = (props) => { + + const {open, onOpenChange, identityProviderId} = props + + type ProviderTypeValue = "OIDC" | "GOOGLE" | "GITHUB" | "GITLAB" | "DISCORD" | "MICROSOFT" | "SAML" + + const PROVIDER_TYPES: { value: ProviderTypeValue, label: string, icon: React.ReactNode }[] = [ + {value: "OIDC", label: "OpenID Connect", icon: }, + {value: "GOOGLE", label: "Google", icon: }, + {value: "GITHUB", label: "GitHub", icon: }, + {value: "GITLAB", label: "GitLab", icon: }, + {value: "DISCORD", label: "Discord", icon: }, + {value: "MICROSOFT", label: "Microsoft", icon: }, + {value: "SAML", label: "SAML", icon: }, + ] + + const applicationService = useService(ApplicationService) + const applicationStore = useStore(ApplicationService) + + const providers = React.useMemo( + () => (applicationService.get()?.settings?.identityProviders?.nodes ?? []).filter((n): n is IdentityProvider => !!n), + [applicationStore] + ) + + const provider = React.useMemo( + () => identityProviderId ? providers.find(p => p.id === identityProviderId) : undefined, + [providers, identityProviderId] + ) + + const oidc = provider?.config?.__typename === "OidcIdentityProviderConfig" + ? provider.config as OidcIdentityProviderConfig + : undefined + const saml = provider?.config?.__typename === "SamlIdentityProviderConfig" + ? provider.config as SamlIdentityProviderConfig + : undefined + + const [type, setType] = React.useState((provider?.type as ProviderTypeValue) ?? "OIDC") + + const typeRef = React.useRef(type) + typeRef.current = type + + const initialValues = React.useMemo(() => ({ + providerName: provider?.config?.providerName ?? "", + clientId: oidc?.clientId ?? "", + clientSecret: oidc?.clientSecret ?? "", + authorizationUrl: oidc?.authorizationUrl ?? "", + tokenUrl: oidc?.tokenUrl ?? "", + userDetailsUrl: oidc?.userDetailsUrl ?? "", + redirectUri: oidc?.redirectUri ?? "", + metadataUrl: saml?.metadataUrl ?? "", + }), [provider]) + + const [inputs, validate] = useForm<{ + providerName: string, + clientId: string, + clientSecret: string, + authorizationUrl: string, + tokenUrl: string, + userDetailsUrl: string, + redirectUri: string, + metadataUrl: string, + }>({ + useInitialValidation: false, + initialValues: initialValues, + validate: { + providerName: (value) => (!value ? "Provider name is required" : null), + clientId: (value) => (typeRef.current !== "SAML" && !value ? "Client ID is required" : null), + clientSecret: (value) => (typeRef.current !== "SAML" && !value ? "Client secret is required" : null), + redirectUri: (value) => (typeRef.current !== "SAML" && !value ? "Redirect URI is required" : null), + authorizationUrl: (value) => (typeRef.current === "OIDC" && !value ? "Authorization URL is required" : null), + tokenUrl: (value) => (typeRef.current === "OIDC" && !value ? "Token URL is required" : null), + userDetailsUrl: (value) => (typeRef.current === "OIDC" && !value ? "User details URL is required" : null), + metadataUrl: (value) => (typeRef.current === "SAML" && !value ? "Metadata URL is required" : null), + }, + onSubmit: (values) => { + const id = provider?.id + ?? (() => { + const base = values.providerName.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || type.toLowerCase() + const taken = new Set(providers.map(p => p.id)) + if (!taken.has(base)) return base + let index = 2 + while (taken.has(`${base}-${index}`)) index++ + return `${base}-${index}` + })() + + const config: IdentityProviderConfigInput = type === "SAML" + ? { + providerName: values.providerName, + metadataUrl: values.metadataUrl, + settings: saml?.settings, + responseSettings: saml?.responseSettings, + attributeStatements: saml?.attributeStatements ?? {}, + } + : { + providerName: values.providerName, + clientId: values.clientId, + clientSecret: values.clientSecret, + redirectUri: values.redirectUri, + ...(type === "OIDC" ? { + authorizationUrl: values.authorizationUrl, + tokenUrl: values.tokenUrl, + userDetailsUrl: values.userDetailsUrl, + attributeStatements: oidc?.attributeStatements ?? {}, + } : {}), + } + + const input: IdentityProviderInput = {id, type: type as IdentityProviderType, config} + const others = (applicationService.get()?.settings?.identityProviders?.nodes ?? []) + .filter((n): n is IdentityProvider => !!n) + .filter(p => p.id !== input.id) + .map(p => ({ + id: p.id!, + type: p.type!, + config: !p.config + ? {} + : p.config.__typename === "SamlIdentityProviderConfig" + ? { + providerName: p.config.providerName, + metadataUrl: p.config.metadataUrl, + settings: p.config.settings, + responseSettings: p.config.responseSettings, + attributeStatements: p.config.attributeStatements, + } + : { + providerName: (p.config as OidcIdentityProviderConfig).providerName, + clientId: (p.config as OidcIdentityProviderConfig).clientId, + clientSecret: (p.config as OidcIdentityProviderConfig).clientSecret, + redirectUri: (p.config as OidcIdentityProviderConfig).redirectUri, + ...(p.type === "OIDC" ? { + authorizationUrl: (p.config as OidcIdentityProviderConfig).authorizationUrl, + tokenUrl: (p.config as OidcIdentityProviderConfig).tokenUrl, + userDetailsUrl: (p.config as OidcIdentityProviderConfig).userDetailsUrl, + attributeStatements: (p.config as OidcIdentityProviderConfig).attributeStatements, + } : {}), + } + })) + const next: IdentityProviderInput[] = [...others, input] + + startTransition(() => { + applicationService.applicationUpdate({identityProviders: next}).then(payload => { + if ((payload?.errors?.length ?? 0) <= 0) { + toast({title: provider ? "Updated identity provider" : "Added identity provider", color: "success"}) + onOpenChange?.(false) + } + }) + }) + } + }) + + return onOpenChange?.(open)}> + Provider type + The kind of identity provider to connect. + setType(value as ProviderTypeValue)}> + + + + + + + + + + + + {PROVIDER_TYPES.map(t => ( + + + + {t.icon} + {t.label} + + + + ))} + + + + + + + {type === "SAML" ? ( + <> + + + + ) : ( + <> + + + + + {type === "OIDC" && ( + <> + + + + + + + + )} + + + + )} + + + +} diff --git a/src/packages/ce/src/application/pages/IdentityProviderCreatePage.tsx b/src/packages/ce/src/application/pages/IdentityProviderCreatePage.tsx new file mode 100644 index 00000000..34813acb --- /dev/null +++ b/src/packages/ce/src/application/pages/IdentityProviderCreatePage.tsx @@ -0,0 +1,41 @@ +"use client" + +import React from "react"; +import {useService, useStore} from "@code0-tech/pictor"; +import {notFound, useRouter} from "next/navigation"; +import {UserService} from "@edition/user/services/User.service"; +import {useUserSession} from "@edition/user/hooks/User.session.hook"; +import {IdentityProviderSettingDialogComponent} from "@edition/application/components/IdentityProviderSettingDialogComponent"; + +export const IdentityProviderCreatePage: React.FC = () => { + + const router = useRouter() + const userStore = useStore(UserService) + const userService = useService(UserService) + + const currentSession = useUserSession() + + const currentUser = React.useMemo( + () => userService.getById(currentSession?.user?.id), + [userStore, currentSession] + ) + + if (currentUser && !currentUser.admin) { + notFound() + } + + return { + if (open) return + + const nav = (window as unknown as { navigation?: { entries(): { url: string }[], currentEntry?: { index: number } } }).navigation + if (!nav?.entries) { + router.back() + return + } + + const index = nav.currentEntry?.index ?? 0 + if (index > 0) router.back() + else router.push("/settings") + }}/> +} diff --git a/src/packages/ce/src/application/pages/IdentityProviderSettingsPage.tsx b/src/packages/ce/src/application/pages/IdentityProviderSettingsPage.tsx new file mode 100644 index 00000000..63b88163 --- /dev/null +++ b/src/packages/ce/src/application/pages/IdentityProviderSettingsPage.tsx @@ -0,0 +1,45 @@ +"use client" + +import React from "react"; +import {useService, useStore} from "@code0-tech/pictor"; +import {notFound, useParams, useRouter} from "next/navigation"; +import {UserService} from "@edition/user/services/User.service"; +import {useUserSession} from "@edition/user/hooks/User.session.hook"; +import {IdentityProviderSettingDialogComponent} from "@edition/application/components/IdentityProviderSettingDialogComponent"; + +export const IdentityProviderSettingsPage: React.FC = () => { + + const params = useParams() + const router = useRouter() + const identityProviderId = decodeURIComponent(params.identityProviderId as string) + + const userStore = useStore(UserService) + const userService = useService(UserService) + + const currentSession = useUserSession() + + const currentUser = React.useMemo( + () => userService.getById(currentSession?.user?.id), + [userStore, currentSession] + ) + + if (currentUser && !currentUser.admin) { + notFound() + } + + return { + if (open) return + + const nav = (window as unknown as { navigation?: { entries(): { url: string }[], currentEntry?: { index: number } } }).navigation + if (!nav?.entries) { + router.back() + return + } + + const index = nav.currentEntry?.index ?? 0 + if (index > 0) router.back() + else router.push("/settings") + }}/> +} diff --git a/src/packages/ce/src/application/services/Application.service.ts b/src/packages/ce/src/application/services/Application.service.ts index 645641c4..86cbb159 100644 --- a/src/packages/ce/src/application/services/Application.service.ts +++ b/src/packages/ce/src/application/services/Application.service.ts @@ -10,6 +10,7 @@ import {Payload, View} from "@code0-tech/pictor/dist/utils/view"; import {GraphqlClient} from "@core/util/graphql-client"; import applicationQuery from "@edition/application/services/queries/Application.query.graphql" import applicationUpdateMutation from "@edition/application/services/mutations/Application.update.mutation.graphql" +import identityProviderLoginUrlQuery from "@edition/application/services/queries/IdentityProviderLoginUrl.query.graphql" export type Application = SApplication & Payload @@ -50,13 +51,34 @@ export class ApplicationService extends ReactiveArrayService { if (result.data && result.data.applicationSettingsUpdate && result.data.applicationSettingsUpdate.applicationSettings) { const application = this.get() + const errored = (result.data.applicationSettingsUpdate.errors?.length ?? 0) > 0 + const identityProviders = payload.identityProviders != null && !errored + ? { + ...application.settings?.identityProviders, + __typename: "IdentityProviderConnection", + count: payload.identityProviders.length, + nodes: payload.identityProviders.map(input => ({ + __typename: "IdentityProvider", + id: input.id, + type: input.type, + config: input.config ? { + __typename: input.type === "SAML" + ? "SamlIdentityProviderConfig" + : "OidcIdentityProviderConfig", + ...input.config + } : null + })) + } + : application.settings?.identityProviders + this.set(0, new View({ ...application, legalNoticeUrl: result.data.applicationSettingsUpdate.applicationSettings.legalNoticeUrl, privacyUrl: result.data.applicationSettingsUpdate.applicationSettings.privacyUrl, termsAndConditionsUrl: result.data.applicationSettingsUpdate.applicationSettings.termsAndConditionsUrl, settings: { - ...result.data.applicationSettingsUpdate.applicationSettings + ...result.data.applicationSettingsUpdate.applicationSettings, + identityProviders } } as Application)) @@ -64,4 +86,13 @@ export class ApplicationService extends ReactiveArrayService { return result.data?.applicationSettingsUpdate ?? undefined } + + async getIdentityProviderLoginUrl(id: string): Promise { + const result = await this.client.query({ + query: identityProviderLoginUrlQuery, + variables: {id} + }) + + return result.data?.application?.identityProviderLoginUrl ?? undefined + } } \ No newline at end of file diff --git a/src/packages/ce/src/application/services/fragments/Application.fragment.graphql b/src/packages/ce/src/application/services/fragments/Application.fragment.graphql index f4d55281..9a4808bf 100644 --- a/src/packages/ce/src/application/services/fragments/Application.fragment.graphql +++ b/src/packages/ce/src/application/services/fragments/Application.fragment.graphql @@ -1,3 +1,5 @@ +#import "@edition/application/services/fragments/IdentityProvider.fragment.graphql" + fragment Application on Application { __typename metadata { @@ -8,6 +10,19 @@ fragment Application on Application { legalNoticeUrl privacyUrl termsAndConditionsUrl + identityProviders(first: $firstIdentityProvider, after: $afterIdentityProvider) { + __typename + count + pageInfo { + endCursor + hasNextPage + } + nodes { + __typename + id + type + } + } settings { __typename termsAndConditionsUrl @@ -17,6 +32,17 @@ fragment Application on Application { organizationCreationRestricted userRegistrationEnabled runtimeMaxHeartbeatIntervalMinutes + identityProviders(first: $firstIdentityProvider, after: $afterIdentityProvider) { + __typename + count + pageInfo { + endCursor + hasNextPage + } + nodes { + ...IdentityProvider + } + } } userAbilities { __typename diff --git a/src/packages/ce/src/application/services/fragments/IdentityProvider.fragment.graphql b/src/packages/ce/src/application/services/fragments/IdentityProvider.fragment.graphql new file mode 100644 index 00000000..34321eb4 --- /dev/null +++ b/src/packages/ce/src/application/services/fragments/IdentityProvider.fragment.graphql @@ -0,0 +1,25 @@ +fragment IdentityProvider on IdentityProvider { + __typename + id + type + config { + __typename + ... on OidcIdentityProviderConfig { + providerName + clientId + clientSecret + authorizationUrl + tokenUrl + userDetailsUrl + redirectUri + attributeStatements + } + ... on SamlIdentityProviderConfig { + providerName + metadataUrl + settings + responseSettings + attributeStatements + } + } +} diff --git a/src/packages/ce/src/application/services/mutations/Application.update.mutation.graphql b/src/packages/ce/src/application/services/mutations/Application.update.mutation.graphql index 030ac89f..da132e95 100644 --- a/src/packages/ce/src/application/services/mutations/Application.update.mutation.graphql +++ b/src/packages/ce/src/application/services/mutations/Application.update.mutation.graphql @@ -1,4 +1,4 @@ -mutation ApplicationUpdate($userRegistrationEnabled: Boolean, $organizationCreationRestricted: Boolean, $adminStatusVisible: Boolean, $termsAndConditionsUrl: String, $privacyUrl: String, $legalNoticeUrl: String, $runtimeMaxHeartbeatIntervalMinutes: Int) { +mutation ApplicationUpdate($userRegistrationEnabled: Boolean, $organizationCreationRestricted: Boolean, $adminStatusVisible: Boolean, $termsAndConditionsUrl: String, $privacyUrl: String, $legalNoticeUrl: String, $runtimeMaxHeartbeatIntervalMinutes: Int, $identityProviders: [IdentityProviderInput!]) { applicationSettingsUpdate(input: { userRegistrationEnabled: $userRegistrationEnabled organizationCreationRestricted: $organizationCreationRestricted @@ -7,6 +7,7 @@ mutation ApplicationUpdate($userRegistrationEnabled: Boolean, $organizationCreat privacyUrl: $privacyUrl legalNoticeUrl: $legalNoticeUrl runtimeMaxHeartbeatIntervalMinutes: $runtimeMaxHeartbeatIntervalMinutes + identityProviders: $identityProviders }) { errors { ...on Error { @@ -28,4 +29,4 @@ mutation ApplicationUpdate($userRegistrationEnabled: Boolean, $organizationCreat runtimeMaxHeartbeatIntervalMinutes } } -} \ No newline at end of file +} diff --git a/src/packages/ce/src/application/services/queries/Application.query.graphql b/src/packages/ce/src/application/services/queries/Application.query.graphql index b92812c7..2a87dd39 100644 --- a/src/packages/ce/src/application/services/queries/Application.query.graphql +++ b/src/packages/ce/src/application/services/queries/Application.query.graphql @@ -1,6 +1,9 @@ #import "../fragments/Application.fragment.graphql" -query Application { +query Application( + $firstIdentityProvider: Int, + $afterIdentityProvider: String +) { application { ...Application } diff --git a/src/packages/ce/src/application/services/queries/IdentityProviderLoginUrl.query.graphql b/src/packages/ce/src/application/services/queries/IdentityProviderLoginUrl.query.graphql new file mode 100644 index 00000000..ccc7952c --- /dev/null +++ b/src/packages/ce/src/application/services/queries/IdentityProviderLoginUrl.query.graphql @@ -0,0 +1,5 @@ +query IdentityProviderLoginUrl($id: String!) { + application { + identityProviderLoginUrl(id: $id) + } +} diff --git a/src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx b/src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx new file mode 100644 index 00000000..4abbea9b --- /dev/null +++ b/src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx @@ -0,0 +1,171 @@ +"use client" + +import React, {startTransition} from "react"; +import { + Badge, + Button, + ButtonGroup, + DataTable, + DataTableColumn, + Flex, + Menu, + MenuContent, + MenuItem, + MenuPortal, + MenuTrigger, + Spacing, + Text, + useService, + useStore +} from "@code0-tech/pictor"; +import {TabContent} from "@code0-tech/pictor/dist/components/tab/Tab"; +import {toast} from "@code0-tech/pictor/dist/components/toast/Toast"; +import { + IconBrandDiscord, + IconBrandGithub, + IconBrandGitlab, + IconBrandGoogle, + IconBrandWindows, + IconDotsVertical, + IconFingerprint, + IconKey, + IconPlus, + IconShieldLock, + IconTrash +} from "@tabler/icons-react"; +import Link from "next/link"; +import {useRouter} from "next/navigation"; +import type { + IdentityProvider, + IdentityProviderInput, + OidcIdentityProviderConfig +} from "@code0-tech/sagittarius-graphql-types"; +import {ApplicationService} from "@edition/application/services/Application.service"; + +export const ApplicationIdentityProvidersView: React.FC = () => { + + type ProviderTypeValue = "OIDC" | "GOOGLE" | "GITHUB" | "GITLAB" | "DISCORD" | "MICROSOFT" | "SAML" + + const PROVIDER_TYPES: { value: ProviderTypeValue, label: string, icon: React.ReactNode }[] = [ + {value: "OIDC", label: "OpenID Connect", icon: }, + {value: "GOOGLE", label: "Google", icon: }, + {value: "GITHUB", label: "GitHub", icon: }, + {value: "GITLAB", label: "GitLab", icon: }, + {value: "DISCORD", label: "Discord", icon: }, + {value: "MICROSOFT", label: "Microsoft", icon: }, + {value: "SAML", label: "SAML", icon: }, + ] + + const router = useRouter() + const applicationService = useService(ApplicationService) + const applicationStore = useStore(ApplicationService) + + const application = React.useMemo(() => applicationService.get(), [applicationStore]) + + const providers = React.useMemo( + () => (application?.settings?.identityProviders?.nodes ?? []).filter((n): n is IdentityProvider => !!n), + [application] + ) + + const handleDelete = React.useCallback((id: string) => { + const next: IdentityProviderInput[] = providers.filter(p => p.id !== id).map((p): IdentityProviderInput => ({ + id: p.id!, + type: p.type!, + config: !p.config + ? {} + : p.config.__typename === "SamlIdentityProviderConfig" + ? { + providerName: p.config.providerName, + metadataUrl: p.config.metadataUrl, + settings: p.config.settings, + responseSettings: p.config.responseSettings, + attributeStatements: p.config.attributeStatements, + } + : { + providerName: (p.config as OidcIdentityProviderConfig).providerName, + clientId: (p.config as OidcIdentityProviderConfig).clientId, + clientSecret: (p.config as OidcIdentityProviderConfig).clientSecret, + redirectUri: (p.config as OidcIdentityProviderConfig).redirectUri, + ...(p.type === "OIDC" ? { + authorizationUrl: (p.config as OidcIdentityProviderConfig).authorizationUrl, + tokenUrl: (p.config as OidcIdentityProviderConfig).tokenUrl, + userDetailsUrl: (p.config as OidcIdentityProviderConfig).userDetailsUrl, + attributeStatements: (p.config as OidcIdentityProviderConfig).attributeStatements, + } : {}), + } + })) + startTransition(() => { + applicationService.applicationUpdate({identityProviders: next}).then(payload => { + if ((payload?.errors?.length ?? 0) <= 0) { + toast({title: "Removed identity provider", color: "success"}) + } + }) + }) + }, [providers, applicationService]) + + return + + + Identity providers + {providers.length} + + + + + + + + + + Configure OAuth / OpenID Connect providers your users can log in and register with. + + + { + if (provider?.id) router.push(`/settings/identity-providers/${encodeURIComponent(provider.id)}/settings`) + }} + emptyComponent={ + + No identity providers configured. Add one to enable single sign-on. + + } + data={providers}> + {(provider) => { + const meta = PROVIDER_TYPES.find(t => t.value === provider.type) + ?? {value: provider.type, label: provider.type ?? "Unknown", icon: } + return <> + + + {meta.icon} + + {provider.type === "SAML" || provider.type === "OIDC" + ? provider.config?.providerName || provider.id + : meta.label} + + + + e.stopPropagation()}> + + + + + + + handleDelete(provider.id!)}> + + Remove + + + + + + + }} + + +} diff --git a/src/packages/ce/src/application/views/ApplicationStatsView.tsx b/src/packages/ce/src/application/views/ApplicationStatsView.tsx index 3a91dfd0..3ca1bb1d 100644 --- a/src/packages/ce/src/application/views/ApplicationStatsView.tsx +++ b/src/packages/ce/src/application/views/ApplicationStatsView.tsx @@ -64,7 +64,7 @@ export const ApplicationStatsView: React.FC = () => { // Saved work = executed nodes across a set of flows, pressed into whole minutes. const savedMinutesOf = (flowSet: typeof flows) => Math.floor(flowSet.reduce((sum, flow) => - sum + (flow.nodes?.count ?? 0) * (flow.executionResults?.count ?? 0), 0) + sum + (flow.nodes?.count ?? 0) * (flow.executionResults?.count ?? 0), 0) * SAVED_SECONDS_PER_NODE_RUN / 60) const savedMinutes = React.useMemo(() => savedMinutesOf(flows), [flows]) diff --git a/src/packages/ce/src/flow/services/Flow.service.ts b/src/packages/ce/src/flow/services/Flow.service.ts index c58e8272..d47ae055 100644 --- a/src/packages/ce/src/flow/services/Flow.service.ts +++ b/src/packages/ce/src/flow/services/Flow.service.ts @@ -563,6 +563,7 @@ export class FlowService extends ReactiveArrayService { return <> - Workspaces + Workspaces {visibleNamespaces.length} @@ -149,6 +152,10 @@ export const NamespaceRowView: React.FC = () => { + + Manage users who have access to your instance. You can invite new users and remove existing ones. + + {/* ── The grid users choose from; create sits in the same grid ── */} @@ -160,36 +167,36 @@ export const NamespaceRowView: React.FC = () => { ? userService.getById(namespace.parent.id) : undefined return - - - {/* identity: avatar (user avatar for personal, identicon for org), name and personal marker */} - - {isPersonal - ? - : } - - {name} - {isPersonal && Personal} + + + {/* identity: avatar (user avatar for personal, identicon for org), name and personal marker */} + + {isPersonal + ? + : } + + {name} + {isPersonal && Personal} + - - {/* metadata: one calm, labelled line */} - - - - - {namespace.projects?.count ?? 0} projects - - - - - - {namespace.members?.count ?? 0} members - + {/* metadata: one calm, labelled line */} + + + + + {namespace.projects?.count ?? 0} projects + + + + + + {namespace.members?.count ?? 0} members + + - - + })} @@ -199,7 +206,6 @@ export const NamespaceRowView: React.FC = () => { + })} + +} diff --git a/src/packages/ce/src/user/components/UserEditDialogComponent.tsx b/src/packages/ce/src/user/components/UserEditDialogComponent.tsx index e9c71e78..d4d71033 100644 --- a/src/packages/ce/src/user/components/UserEditDialogComponent.tsx +++ b/src/packages/ce/src/user/components/UserEditDialogComponent.tsx @@ -29,6 +29,7 @@ import {toast} from "@code0-tech/pictor/dist/components/toast/Toast"; import { IconAt, IconBackground, + IconLink, IconLock, IconMail, IconSettings2, @@ -38,6 +39,7 @@ import { import {UserSessionsDataTableComponent} from "@edition/user/components/UserSessionsDataTableComponent"; import {useMfa} from "@edition/user/components/MfaProviderComponent"; import {UserMfaView} from "@edition/user/views/UserMfaView"; +import {UserIdentitiesView} from "@edition/user/views/UserIdentitiesView"; import {SettingDialog} from "@core/components/SettingDialog"; export interface UserEditDialogComponentProps { @@ -177,6 +179,14 @@ export const UserEditDialogComponent: React.FC = ( )} + {isSelf && ( + + + + )} + + +} diff --git a/src/packages/ce/src/user/pages/UserCallbackPage.tsx b/src/packages/ce/src/user/pages/UserCallbackPage.tsx new file mode 100644 index 00000000..68e55ff7 --- /dev/null +++ b/src/packages/ce/src/user/pages/UserCallbackPage.tsx @@ -0,0 +1,128 @@ +"use client"; + +import React from "react"; +import {Alert, Button, Flex, Spacing, Text, useService} from "@code0-tech/pictor"; +import {UserService} from "@edition/user/services/User.service"; +import {useRouter, useSearchParams} from "next/navigation"; +import {IconLoader2} from "@tabler/icons-react"; +import {motion} from "framer-motion"; +import {setUserSession} from "@edition/user/hooks/User.session.hook"; + +export const UserCallbackPage: React.FC = () => { + + type IdentityAuthState = { + intent: "login" | "register" | "link" + providerId: string + returnTo?: string + } + + const userService = useService(UserService) + const params = useSearchParams() + const router = useRouter() + const [error, setError] = React.useState(null) + const handled = React.useRef(false) + + const state = React.useMemo(() => { + const raw = params.get("state") + if (!raw) return null + try { + const json = JSON.parse(atob(raw.replace(/-/g, "+").replace(/_/g, "/"))) + const intent = json?.intent + if ((intent !== "login" && intent !== "register" && intent !== "link") + || typeof json?.providerId !== "string" || !json.providerId) return null + return { + intent, + providerId: json.providerId, + returnTo: typeof json.returnTo === "string" ? json.returnTo : undefined, + } + } catch { + return null + } + }, [params]) + + React.useEffect(() => { + if (handled.current) return + handled.current = true + + const providerError = params.get("error_description") || params.get("error") + const code = params.get("code") + + if (providerError) { + setError(providerError) + return + } + if (!code || !state) { + setError("This sign-in link is invalid or has expired. Please try again.") + return + } + + const args = {providerId: state.providerId, args: {code}} + + if (state.intent === "link") { + userService.usersIdentityLink(args).then(payload => { + if ((payload?.errors?.length ?? 0) > 0) { + setError("We couldn't link this provider to your account. Please try again.") + return + } + router.push("/") + router.refresh() + }) + return + } + + const authenticate = state.intent === "register" + ? userService.usersIdentityRegister(args) + : userService.usersIdentityLogin(args) + + authenticate.then(payload => { + if ((payload?.errors?.length ?? 0) > 0) { + setError(state.intent === "register" + ? "We couldn't create your account with this provider. Please try again." + : "We couldn't sign you in with this provider. Please try again.") + return + } + if (!payload?.userSession) { + setError("We couldn't complete the sign-in. Please try again.") + return + } + setUserSession(payload.userSession) + router.push("/") + router.refresh() + }) + }, []) + + if (error) { + const failure = state?.intent === "link" + ? {title: "Linking failed", cta: "Back to overview", target: "/"} + : state?.intent === "register" + ? {title: "Registration failed", cta: "Back to registration", target: "/register"} + : {title: "Sign-in failed", cta: "Back to login", target: "/login"} + return <> + + {failure.title} + + + + Build high-class workflows, endpoints and software without coding + + + {error} + + + + } + + return + + + + + Completing your sign-in… + + +} diff --git a/src/packages/ce/src/user/pages/UserLoginPage.tsx b/src/packages/ce/src/user/pages/UserLoginPage.tsx index 5133ad6b..44354480 100644 --- a/src/packages/ce/src/user/pages/UserLoginPage.tsx +++ b/src/packages/ce/src/user/pages/UserLoginPage.tsx @@ -21,6 +21,7 @@ import {useRouter, useSearchParams} from "next/navigation"; import {setUserSession} from "@edition/user/hooks/User.session.hook"; import {MfaInputComponent} from "@edition/user/components/MfaInputComponent"; import {MfaType} from "@code0-tech/sagittarius-graphql-types"; +import {IdentityProviderButtonsComponent} from "@edition/user/components/IdentityProviderButtonsComponent"; export const UserLoginPage: React.FC = () => { @@ -197,15 +198,25 @@ export const UserLoginPage: React.FC = () => { ) : null} -
+ -
- + +
+
+ + or continue with + +
+
+ + + Forgot password? diff --git a/src/packages/ce/src/user/pages/UserRegistrationPage.tsx b/src/packages/ce/src/user/pages/UserRegistrationPage.tsx index 4307c0c0..d756d189 100644 --- a/src/packages/ce/src/user/pages/UserRegistrationPage.tsx +++ b/src/packages/ce/src/user/pages/UserRegistrationPage.tsx @@ -8,6 +8,7 @@ import { Flex, PasswordInput, passwordValidation, + Spacing, Text, TextInput, useForm, @@ -17,6 +18,7 @@ import Link from "next/link"; import {UserService} from "@edition/user/services/User.service"; import {useRouter} from "next/navigation"; import {setUserSession} from "@edition/user/hooks/User.session.hook"; +import {IdentityProviderButtonsComponent} from "@edition/user/components/IdentityProviderButtonsComponent"; export const UserRegistrationPage: React.FC = () => { @@ -98,6 +100,15 @@ export const UserRegistrationPage: React.FC = () => { +
+
+ + or sign up with + +
+
+ + Have an account diff --git a/src/packages/ce/src/user/services/User.service.ts b/src/packages/ce/src/user/services/User.service.ts index ac4a3ae3..008a5209 100644 --- a/src/packages/ce/src/user/services/User.service.ts +++ b/src/packages/ce/src/user/services/User.service.ts @@ -40,6 +40,10 @@ import updateMutation from "./mutations/User.update.mutation.graphql"; import loginMutation from "./mutations/User.login.mutation.graphql"; import logoutMutation from "./mutations/User.logout.mutation.graphql"; import registerMutation from "./mutations/User.register.mutation.graphql"; +import identityLoginMutation from "./mutations/User.identityLogin.mutation.graphql"; +import identityRegisterMutation from "./mutations/User.identityRegister.mutation.graphql"; +import identityLinkMutation from "./mutations/User.identityLink.mutation.graphql"; +import identityUnlinkMutation from "./mutations/User.identityUnlink.mutation.graphql"; import emailVerificationMutation from "./mutations/User.emailVerification.mutation.graphql"; import passwordResetMutation from "./mutations/User.passwordReset.mutation.graphql" import passwordResetRequestMutation from "./mutations/User.passwordResetRequest.mutation.graphql" @@ -229,24 +233,74 @@ export class UserService extends ReactiveArrayService { return result.data?.usersEmailVerification ?? undefined } - /** @alpha **/ - usersIdentityLink(payload: UsersIdentityLinkInput): Promise { - return Promise.resolve(undefined); + async usersIdentityLink(payload: UsersIdentityLinkInput): Promise { + const result = await this.client.mutate({ + mutation: identityLinkMutation, + variables: { + ...payload + } + }) + + return result.data?.usersIdentityLink ?? undefined } - /** @alpha **/ - usersIdentityLogin(payload: UsersIdentityLoginInput): Promise { - return Promise.resolve(undefined); + async usersIdentityLogin(payload: UsersIdentityLoginInput): Promise { + const result = await this.client.mutate({ + mutation: identityLoginMutation, + variables: { + ...payload + } + }) + + const user = result.data?.usersIdentityLogin?.userSession?.user + if (user && !this.hasById(user.id)) { + this.add(new View(user)) + } + + return result.data?.usersIdentityLogin ?? undefined } - /** @alpha **/ - usersIdentityRegister(payload: UsersIdentityRegisterInput): Promise { - return Promise.resolve(undefined); + async usersIdentityRegister(payload: UsersIdentityRegisterInput): Promise { + const result = await this.client.mutate({ + mutation: identityRegisterMutation, + variables: { + ...payload + } + }) + + const user = result.data?.usersIdentityRegister?.userSession?.user + if (user && !this.hasById(user.id)) { + this.add(new View(user)) + } + + return result.data?.usersIdentityRegister ?? undefined } - /** @alpha **/ - usersIdentityUnlink(payload: UsersIdentityUnlinkInput): Promise { - return Promise.resolve(undefined); + async usersIdentityUnlink(payload: UsersIdentityUnlinkInput): Promise { + const result = await this.client.mutate({ + mutation: identityUnlinkMutation, + variables: { + ...payload + } + }) + + const removed = result.data?.usersIdentityUnlink?.userIdentity + if (removed && (result.data?.usersIdentityUnlink?.errors?.length ?? 0) <= 0) { + const index = super.values().findIndex(user => user?.identities?.nodes?.some(identity => identity?.id === removed.id)) + const user = index >= 0 ? super.values()[index] : undefined + if (user?.identities) { + this.set(index, new View({ + ...user, + identities: { + ...user.identities, + count: Math.max((user.identities.count ?? 1) - 1, 0), + nodes: user.identities.nodes?.filter(identity => identity?.id !== removed.id) ?? [] + } + })) + } + } + + return result.data?.usersIdentityUnlink ?? undefined } async usersLogin(payload: UsersLoginInput): Promise { diff --git a/src/packages/ce/src/user/services/mutations/User.identityLink.mutation.graphql b/src/packages/ce/src/user/services/mutations/User.identityLink.mutation.graphql new file mode 100644 index 00000000..7627e4ad --- /dev/null +++ b/src/packages/ce/src/user/services/mutations/User.identityLink.mutation.graphql @@ -0,0 +1,22 @@ +mutation identityLink($providerId: String!, $args: IdentityInput!) { + usersIdentityLink(input: { + providerId: $providerId + args: $args + }) { + errors { + ...on Error { + errorCode, + details { + __typename + } + } + } + userIdentity { + id + providerId + identifier + createdAt + updatedAt + } + } +} diff --git a/src/packages/ce/src/user/services/mutations/User.identityLogin.mutation.graphql b/src/packages/ce/src/user/services/mutations/User.identityLogin.mutation.graphql new file mode 100644 index 00000000..6f5d8f4e --- /dev/null +++ b/src/packages/ce/src/user/services/mutations/User.identityLogin.mutation.graphql @@ -0,0 +1,26 @@ +#import "@edition/user/services/fragments/User.basic.fragment.graphql" +mutation identityLogin($providerId: String!, $args: IdentityInput!) { + usersIdentityLogin(input: { + providerId: $providerId + args: $args + }) { + errors { + ...on Error { + errorCode, + details { + __typename + } + } + } + userSession { + updatedAt + token + active + createdAt + id + user { + ...UserBasic + } + } + } +} diff --git a/src/packages/ce/src/user/services/mutations/User.identityRegister.mutation.graphql b/src/packages/ce/src/user/services/mutations/User.identityRegister.mutation.graphql new file mode 100644 index 00000000..9dff4d27 --- /dev/null +++ b/src/packages/ce/src/user/services/mutations/User.identityRegister.mutation.graphql @@ -0,0 +1,26 @@ +#import "@edition/user/services/fragments/User.basic.fragment.graphql" +mutation identityRegister($providerId: String!, $args: IdentityInput!) { + usersIdentityRegister(input: { + providerId: $providerId + args: $args + }) { + errors { + ...on Error { + errorCode, + details { + __typename + } + } + } + userSession { + updatedAt + token + active + createdAt + id + user { + ...UserBasic + } + } + } +} diff --git a/src/packages/ce/src/user/services/mutations/User.identityUnlink.mutation.graphql b/src/packages/ce/src/user/services/mutations/User.identityUnlink.mutation.graphql new file mode 100644 index 00000000..e8a624a5 --- /dev/null +++ b/src/packages/ce/src/user/services/mutations/User.identityUnlink.mutation.graphql @@ -0,0 +1,17 @@ +mutation identityUnlink($identityId: UserIdentityID!) { + usersIdentityUnlink(input: { + identityId: $identityId + }) { + errors { + ...on Error { + errorCode, + details { + __typename + } + } + } + userIdentity { + id + } + } +} diff --git a/src/packages/ce/src/user/views/UserIdentitiesView.tsx b/src/packages/ce/src/user/views/UserIdentitiesView.tsx new file mode 100644 index 00000000..e81a2a48 --- /dev/null +++ b/src/packages/ce/src/user/views/UserIdentitiesView.tsx @@ -0,0 +1,63 @@ +"use client" + +import React from "react"; +import {Flex, Spacing, Text, useService, useStore} from "@code0-tech/pictor"; +import {TabContent} from "@code0-tech/pictor/dist/components/tab/Tab"; +import {UserIdentity} from "@code0-tech/sagittarius-graphql-types"; +import {UserService} from "@edition/user/services/User.service"; +import {useUserSession} from "@edition/user/hooks/User.session.hook"; +import {IdentityProviderButtonsComponent} from "@edition/user/components/IdentityProviderButtonsComponent"; +import {UserIdentitiesDataTableComponent} from "@edition/user/components/UserIdentitiesDataTableComponent"; + +export const UserIdentitiesView: React.FC = () => { + + const userService = useService(UserService) + const userStore = useStore(UserService) + const session = useUserSession() + + const user = React.useMemo( + () => userService.getById(session?.user?.id), + [userStore, session] + ) + + const identities = React.useMemo( + () => (user?.identities?.nodes ?? []).filter((node): node is UserIdentity => !!node), + [user] + ) + + const linkedProviderIds = identities + .map(identity => identity.providerId) + .filter((id): id is string => !!id) + + const returnTo = typeof window !== "undefined" + ? window.location.pathname + window.location.search + : undefined + + return + + Connected accounts + + + + Link an identity provider to sign in without a password, or unlink one you no longer use. + + + {identities.length > 0 ? ( + <> +
+ +
+ + + ) : null} + +
+}