From 7582cedb692ac913ebc632e93a094ba3e24612b8 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Fri, 31 Jul 2026 15:40:50 +0200 Subject: [PATCH 01/28] feat: add count field to Flow fragment and update execution results in Flow service --- src/packages/ce/src/flow/services/Flow.service.ts | 1 + .../ce/src/flow/services/fragments/Flow.fragment.graphql | 1 + 2 files changed, 2 insertions(+) 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 Date: Sun, 2 Aug 2026 19:23:07 +0200 Subject: [PATCH 02/28] feat: extend Application query and fragment to include identity providers with pagination --- .../services/fragments/Application.fragment.graphql | 13 +++++++++++++ .../services/queries/Application.query.graphql | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) 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..53655bba 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 { @@ -17,6 +19,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/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 } From eef6121e18becb96139a4378db1e33039909db2f Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 19:37:48 +0200 Subject: [PATCH 03/28] feat: add method to retrieve identity provider login URL in Application service --- .../ce/src/application/services/Application.service.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/packages/ce/src/application/services/Application.service.ts b/src/packages/ce/src/application/services/Application.service.ts index 645641c4..d64bb690 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 @@ -64,4 +65,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 From 3b8d5063dc7f7a41e4ef8aaeb28c7ba730a30f30 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 19:43:44 +0200 Subject: [PATCH 04/28] feat: add identity providers handling in application settings update --- .../services/Application.service.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/packages/ce/src/application/services/Application.service.ts b/src/packages/ce/src/application/services/Application.service.ts index d64bb690..86cbb159 100644 --- a/src/packages/ce/src/application/services/Application.service.ts +++ b/src/packages/ce/src/application/services/Application.service.ts @@ -51,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)) From 6dbbc4f7cf1d3b92f84863617b04158a4fff5d23 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 19:43:49 +0200 Subject: [PATCH 05/28] feat: add identity providers input to application update mutation --- .../services/mutations/Application.update.mutation.graphql | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 +} From 23fcedc260077a14b1208c68d78d1ff000ed92a3 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 19:54:37 +0200 Subject: [PATCH 06/28] feat: add identity providers tab and view to application settings dialog --- .../components/ApplicationSettingsDialogComponent.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 + + + }> + } From 520744ce3f5c50afaf487b104c8a73006332f132 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 19:55:28 +0200 Subject: [PATCH 07/28] fix: correct indentation in savedMinutesOf function for better readability --- src/packages/ce/src/application/views/ApplicationStatsView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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]) From ae7c8541743607d263a60ffadcc864f0172115dd Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 19:55:47 +0200 Subject: [PATCH 08/28] feat: add application service to context store provider for enhanced state management --- src/app/(auth)/layout.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ( - + From a092f070a76c81cc44a30130a2d0e8bf00c82ff6 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 19:56:55 +0200 Subject: [PATCH 09/28] style: improve layout and readability in NamespaceRowView component --- .../src/namespace/views/NamespaceRowView.tsx | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/packages/ce/src/namespace/views/NamespaceRowView.tsx b/src/packages/ce/src/namespace/views/NamespaceRowView.tsx index 8d797d9c..d9e7dbe4 100644 --- a/src/packages/ce/src/namespace/views/NamespaceRowView.tsx +++ b/src/packages/ce/src/namespace/views/NamespaceRowView.tsx @@ -5,16 +5,19 @@ import { Avatar, Badge, Button, - ButtonGroup, Card, + ButtonGroup, + Card, Col, - Flex, hashToColor, + Flex, + hashToColor, Menu, MenuContent, MenuItem, MenuLabel, MenuPortal, MenuTrigger, - Row, Spacing, + Row, + Spacing, Text, useService, useStore @@ -95,7 +98,7 @@ export const NamespaceRowView: React.FC = () => { 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 = () => { )} + {isSelf && ( + + + + )} + })} + +} From fdf9ce29df77dec26bef38e369cb0fdadd909736 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 20:21:07 +0200 Subject: [PATCH 14/28] feat: add user callback and identity provider pages for authentication flow --- src/app/(auth)/callback/page.tsx | 5 +++++ .../[identityProviderId]/settings/page.tsx | 5 +++++ .../@modal/(.)settings/identity-providers/create/page.tsx | 5 +++++ .../[identityProviderId]/settings/page.tsx | 5 +++++ .../(dashboard)/settings/identity-providers/create/page.tsx | 5 +++++ 5 files changed, 25 insertions(+) create mode 100644 src/app/(auth)/callback/page.tsx create mode 100644 src/app/(dashboard)/@modal/(.)settings/identity-providers/[identityProviderId]/settings/page.tsx create mode 100644 src/app/(dashboard)/@modal/(.)settings/identity-providers/create/page.tsx create mode 100644 src/app/(dashboard)/settings/identity-providers/[identityProviderId]/settings/page.tsx create mode 100644 src/app/(dashboard)/settings/identity-providers/create/page.tsx 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/(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 From 5a75b9cd5457cdbbab43d53703fd4282ddb2ed49 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 20:21:17 +0200 Subject: [PATCH 15/28] feat: add IdentityProviderButtonsComponent to login and registration pages for authentication options --- src/packages/ce/src/user/pages/UserLoginPage.tsx | 6 ++++++ src/packages/ce/src/user/pages/UserRegistrationPage.tsx | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/src/packages/ce/src/user/pages/UserLoginPage.tsx b/src/packages/ce/src/user/pages/UserLoginPage.tsx index 5133ad6b..d1964ec4 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 = () => { @@ -206,6 +207,11 @@ export const UserLoginPage: React.FC = () => { {loading ? "Loading..." : "Login"} + + 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..90d0cda1 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,11 @@ export const UserRegistrationPage: React.FC = () => { + + or sign up with + + + Have an account From b858ee30453032833d7968d8bd6179466451e426 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 21:05:23 +0200 Subject: [PATCH 16/28] feat: add IdentityProvider settings dialog and fragment for managing authentication providers --- ...IdentityProviderSettingDialogComponent.tsx | 288 ++++++++++++++++++ .../pages/IdentityProviderSettingsPage.tsx | 45 +++ .../IdentityProvider.fragment.graphql | 25 ++ 3 files changed, 358 insertions(+) create mode 100644 src/packages/ce/src/application/components/IdentityProviderSettingDialogComponent.tsx create mode 100644 src/packages/ce/src/application/pages/IdentityProviderSettingsPage.tsx create mode 100644 src/packages/ce/src/application/services/fragments/IdentityProvider.fragment.graphql 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/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/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 + } + } +} From 898ee85f6378d9a571a84a8e84aba35df31f1488 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 21:06:57 +0200 Subject: [PATCH 17/28] feat: add IdentityProviderCreatePage for creating new authentication providers --- .../pages/IdentityProviderCreatePage.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/packages/ce/src/application/pages/IdentityProviderCreatePage.tsx 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") + }}/> +} From c7c60dee7afbd28d064d5fb4584535f3a21d8dfe Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 21:10:49 +0200 Subject: [PATCH 18/28] feat: add GraphQL mutations and queries for identity provider login, registration, linking, and unlinking --- .../IdentityProviderLoginUrl.query.graphql | 5 ++++ .../User.identityLink.mutation.graphql | 22 ++++++++++++++++ .../User.identityLogin.mutation.graphql | 26 +++++++++++++++++++ .../User.identityRegister.mutation.graphql | 26 +++++++++++++++++++ .../User.identityUnlink.mutation.graphql | 17 ++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 src/packages/ce/src/application/services/queries/IdentityProviderLoginUrl.query.graphql create mode 100644 src/packages/ce/src/user/services/mutations/User.identityLink.mutation.graphql create mode 100644 src/packages/ce/src/user/services/mutations/User.identityLogin.mutation.graphql create mode 100644 src/packages/ce/src/user/services/mutations/User.identityRegister.mutation.graphql create mode 100644 src/packages/ce/src/user/services/mutations/User.identityUnlink.mutation.graphql 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/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 + } + } +} From fc705779be1aa221958b90a0b7fc41d4cfe52455 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 21:27:06 +0200 Subject: [PATCH 19/28] feat: add ApplicationIdentityProvidersView for managing identity providers in the application --- .../ApplicationIdentityProvidersView.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx 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..bac016d4 --- /dev/null +++ b/src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx @@ -0,0 +1,174 @@ +"use client" + +import React, {startTransition} from "react"; +import { + Badge, + Button, + ButtonGroup, + Card, + Flex, + Menu, + MenuContent, + MenuItem, + MenuPortal, + MenuTrigger, + Spacing, + Text, + useService, + useStore +} from "@code0-tech/pictor"; +import CardSection from "@code0-tech/pictor/dist/components/card/CardSection"; +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. + + + {providers.length <= 0 ? ( + + + + No identity providers configured. Add one to enable single sign-on. + + + + ) : ( + + {providers.map(provider => { + const meta = PROVIDER_TYPES.find(t => t.value === provider.type) + ?? {value: provider.type, label: provider.type ?? "Unknown", icon: } + return + + + {meta.icon} + + + {provider.config?.providerName || provider.id} + + {meta.label} + + + + + + + + + router.push(`/settings/identity-providers/${encodeURIComponent(provider.id!)}/settings`)}> + + Configure + + handleDelete(provider.id!)}> + + Remove + + + + + + + })} + + )} + +} From 90bcb792d7d467655095ea290563381bcdffd748 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 21:27:21 +0200 Subject: [PATCH 20/28] feat: add UserIdentitiesDataTableComponent for displaying linked user identities --- .../UserIdentitiesDataTableComponent.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/packages/ce/src/user/components/UserIdentitiesDataTableComponent.tsx diff --git a/src/packages/ce/src/user/components/UserIdentitiesDataTableComponent.tsx b/src/packages/ce/src/user/components/UserIdentitiesDataTableComponent.tsx new file mode 100644 index 00000000..3c3fdeb9 --- /dev/null +++ b/src/packages/ce/src/user/components/UserIdentitiesDataTableComponent.tsx @@ -0,0 +1,65 @@ +"use client" + +import React from "react"; +import { + DataTable, + DataTableColumn, + ScrollArea, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, + Text, + useService, + useStore +} from "@code0-tech/pictor"; +import {IdentityProvider, User, UserIdentity} from "@code0-tech/sagittarius-graphql-types"; +import {UserService} from "@edition/user/services/User.service"; +import {ApplicationService} from "@edition/application/services/Application.service"; +import {UserIdentitiesDataTableRowComponent} from "@edition/user/components/UserIdentitiesDataTableRowComponent"; + +export interface UserIdentitiesDataTableComponentProps { + userId: User['id'] +} + +export const UserIdentitiesDataTableComponent: React.FC = (props) => { + + const {userId} = props + + const userService = useService(UserService) + const userStore = useStore(UserService) + const applicationService = useService(ApplicationService) + const applicationStore = useStore(ApplicationService) + + const providers = React.useMemo( + () => (applicationService.get()?.settings?.identityProviders?.nodes ?? []).filter((node): node is IdentityProvider => !!node), + [applicationStore] + ) + + const identities = React.useMemo( + () => (userService.getById(userId)?.identities?.nodes ?? []).filter((identity): identity is UserIdentity => !!identity), + [userStore, userId] + ) + + const typeByProviderId = React.useMemo( + () => new Map(providers.map(provider => [provider.id, provider.type])), + [providers] + ) + + return + + + You haven't linked any accounts yet. + } + data={identities}> + {(identity) => } + + + + + + +} From f83e9517b3b6d1bbc8ba0995d1eae43bc0b029a1 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 21:27:48 +0200 Subject: [PATCH 21/28] feat: add UserIdentitiesView for managing linked user identities and identity provider actions --- .../ce/src/user/views/UserIdentitiesView.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/packages/ce/src/user/views/UserIdentitiesView.tsx 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..d791c8a2 --- /dev/null +++ b/src/packages/ce/src/user/views/UserIdentitiesView.tsx @@ -0,0 +1,59 @@ +"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. + + +
+ +
+ + +
+} From 616e45f41c5266cd357f821bd1a5db1d0d197a1b Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 22:02:23 +0200 Subject: [PATCH 22/28] feat: add UserCallbackPage for handling user authentication callbacks --- .../ce/src/user/pages/UserCallbackPage.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/packages/ce/src/user/pages/UserCallbackPage.tsx 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… + + +} From 7ebcf131ebc42739b2a3e130e72673b23b0af61e Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sun, 2 Aug 2026 22:02:32 +0200 Subject: [PATCH 23/28] feat: add UserIdentitiesDataTableRowComponent for displaying individual user identity rows --- .../UserIdentitiesDataTableRowComponent.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/packages/ce/src/user/components/UserIdentitiesDataTableRowComponent.tsx diff --git a/src/packages/ce/src/user/components/UserIdentitiesDataTableRowComponent.tsx b/src/packages/ce/src/user/components/UserIdentitiesDataTableRowComponent.tsx new file mode 100644 index 00000000..b38a0392 --- /dev/null +++ b/src/packages/ce/src/user/components/UserIdentitiesDataTableRowComponent.tsx @@ -0,0 +1,82 @@ +"use client" + +import React from "react"; +import {User, UserIdentity} from "@code0-tech/sagittarius-graphql-types"; +import {Button, DataTableColumn, Flex, Text, useService, useStore} from "@code0-tech/pictor"; +import {UserService} from "@edition/user/services/User.service"; +import { + IconBrandDiscord, + IconBrandGithub, + IconBrandGitlab, + IconBrandGoogle, + IconBrandWindows, + IconFingerprint, + IconKey, + IconShieldLock, + IconUnlink +} from "@tabler/icons-react"; +import {toast} from "@code0-tech/pictor/dist/components/toast/Toast"; + +export interface UserIdentitiesDataTableRowComponentProps { + userId: User['id'] + identityId: UserIdentity['id'] + providerType?: string | null +} + +export const UserIdentitiesDataTableRowComponent: React.FC = (props) => { + + const {userId, identityId, providerType} = 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 userService = useService(UserService) + const userStore = useStore(UserService) + + const identity = React.useMemo( + () => userService.getById(userId)?.identities?.nodes?.find(identity => identity?.id === identityId) as UserIdentity | undefined, + [userStore, userId, identityId] + ) + + const unlink = React.useCallback(() => { + if (!identityId) return + userService.usersIdentityUnlink({identityId}).then(payload => { + if ((payload?.errors?.length ?? 0) <= 0) { + toast({title: "Account unlinked", color: "success"}) + } + }) + }, [identityId]) + + if (!identity) return null + + const meta = PROVIDER_TYPES.find(t => t.value === providerType) + ?? {label: providerType ?? identity.providerId ?? "Unknown", icon: } + + return <> + + + {meta.icon} + + {meta.label} + {identity.identifier && ( + {identity.identifier} + )} + + + + + + + +} From 63aa1cfb7c1e82ddc7fd8705b6b8624ccc6b87ba Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 4 Aug 2026 08:42:00 +0200 Subject: [PATCH 24/28] feat: add identityProviders field to Application fragment for enhanced identity provider data --- .../services/fragments/Application.fragment.graphql | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 53655bba..9a4808bf 100644 --- a/src/packages/ce/src/application/services/fragments/Application.fragment.graphql +++ b/src/packages/ce/src/application/services/fragments/Application.fragment.graphql @@ -10,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 From 6407fec3a5e1d734dc86d78d74b9133776f09336 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 4 Aug 2026 08:42:09 +0200 Subject: [PATCH 25/28] feat: replace Card with DataTable for displaying identity providers in a more structured format --- .../ApplicationIdentityProvidersView.tsx | 95 +++++++++---------- 1 file changed, 46 insertions(+), 49 deletions(-) diff --git a/src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx b/src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx index bac016d4..4abbea9b 100644 --- a/src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx +++ b/src/packages/ce/src/application/views/ApplicationIdentityProvidersView.tsx @@ -5,7 +5,8 @@ import { Badge, Button, ButtonGroup, - Card, + DataTable, + DataTableColumn, Flex, Menu, MenuContent, @@ -17,7 +18,6 @@ import { useService, useStore } from "@code0-tech/pictor"; -import CardSection from "@code0-tech/pictor/dist/components/card/CardSection"; import {TabContent} from "@code0-tech/pictor/dist/components/tab/Tab"; import {toast} from "@code0-tech/pictor/dist/components/toast/Toast"; import { @@ -122,53 +122,50 @@ export const ApplicationIdentityProvidersView: React.FC = () => { Configure OAuth / OpenID Connect providers your users can log in and register with.
- {providers.length <= 0 ? ( - - - - No identity providers configured. Add one to enable single sign-on. - - - - ) : ( - - {providers.map(provider => { - const meta = PROVIDER_TYPES.find(t => t.value === provider.type) - ?? {value: provider.type, label: provider.type ?? "Unknown", icon: } - return - - - {meta.icon} - - - {provider.config?.providerName || provider.id} - - {meta.label} - - - - - - - - - router.push(`/settings/identity-providers/${encodeURIComponent(provider.id!)}/settings`)}> - - Configure - - handleDelete(provider.id!)}> - - Remove - - - - + { + 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 + + + + + + + }} + } From 37de84b18b2ab1643c6a787bb8cad1dce9690657 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 4 Aug 2026 08:42:14 +0200 Subject: [PATCH 26/28] feat: update IdentityProviderButtonsComponent to use IdentityProviderBasic and enhance button styling --- .../IdentityProviderButtonsComponent.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/packages/ce/src/user/components/IdentityProviderButtonsComponent.tsx b/src/packages/ce/src/user/components/IdentityProviderButtonsComponent.tsx index ad425958..9f11a615 100644 --- a/src/packages/ce/src/user/components/IdentityProviderButtonsComponent.tsx +++ b/src/packages/ce/src/user/components/IdentityProviderButtonsComponent.tsx @@ -2,7 +2,7 @@ import React from "react"; import {Button, Text, toast, useService, useStore} from "@code0-tech/pictor"; -import {IdentityProvider} from "@code0-tech/sagittarius-graphql-types"; +import {IdentityProviderBasic} from "@code0-tech/sagittarius-graphql-types"; import {useRouter} from "next/navigation"; import {ApplicationService} from "@edition/application/services/Application.service"; import { @@ -33,12 +33,12 @@ export const IdentityProviderButtonsComponent: React.FC( - () => (applicationService.get()?.settings?.identityProviders?.nodes ?? []).filter((node): node is IdentityProvider => !!node), + const providers = React.useMemo( + () => (applicationService.get()?.identityProviders?.nodes ?? []).filter((node): node is IdentityProviderBasic => !!node), [applicationStore] ) - const visibleProviders = React.useMemo( + const visibleProviders = React.useMemo( () => providers.filter(provider => provider.id && !(excludeProviderIds ?? []).includes(provider.id)), [providers, excludeProviderIds] ) @@ -48,21 +48,21 @@ export const IdentityProviderButtonsComponent: React.FC { switch (type) { case "OIDC": - return {label: "OpenID Connect", icon: } + return {label: "OpenID Connect", icon: } case "GOOGLE": - return {label: "Google", icon: } + return {label: "Google", icon: } case "GITHUB": - return {label: "GitHub", icon: } + return {label: "GitHub", icon: } case "GITLAB": - return {label: "GitLab", icon: } + return {label: "GitLab", icon: } case "DISCORD": - return {label: "Discord", icon: } + return {label: "Discord", icon: } case "MICROSOFT": - return {label: "Microsoft", icon: } + return {label: "Microsoft", icon: } case "SAML": - return {label: "SAML", icon: } + return {label: "SAML", icon: } default: - return {label: type ?? "Unknown", icon: } + return {label: type ?? "Unknown", icon: } } }, []) @@ -74,7 +74,7 @@ export const IdentityProviderButtonsComponent: React.FC { + const startAuth = React.useCallback((provider: IdentityProviderBasic) => { const providerId = provider.id if (!providerId || loading) return startTransition(() => { @@ -93,11 +93,13 @@ export const IdentityProviderButtonsComponent: React.FC {visibleProviders.map(provider => { const meta = providerMeta(provider.type) - return })} From ca14459dda06a5be9bac79b097e12acead720a6f Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 4 Aug 2026 08:42:20 +0200 Subject: [PATCH 27/28] feat: conditionally render UserIdentitiesDataTableComponent based on identities length --- .../ce/src/user/views/UserIdentitiesView.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/packages/ce/src/user/views/UserIdentitiesView.tsx b/src/packages/ce/src/user/views/UserIdentitiesView.tsx index d791c8a2..e81a2a48 100644 --- a/src/packages/ce/src/user/views/UserIdentitiesView.tsx +++ b/src/packages/ce/src/user/views/UserIdentitiesView.tsx @@ -48,10 +48,14 @@ export const UserIdentitiesView: React.FC = () => { Link an identity provider to sign in without a password, or unlink one you no longer use.
-
- -
- + {identities.length > 0 ? ( + <> +
+ +
+ + + ) : null} From 037e8cee4945d2161d08ef13b4ce6d3131f695fa Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 4 Aug 2026 08:42:24 +0200 Subject: [PATCH 28/28] feat: enhance layout of login and registration pages with improved spacing and styling --- .../ce/src/user/pages/UserLoginPage.tsx | 21 ++++++++++++------- .../src/user/pages/UserRegistrationPage.tsx | 10 ++++++--- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/packages/ce/src/user/pages/UserLoginPage.tsx b/src/packages/ce/src/user/pages/UserLoginPage.tsx index d1964ec4..44354480 100644 --- a/src/packages/ce/src/user/pages/UserLoginPage.tsx +++ b/src/packages/ce/src/user/pages/UserLoginPage.tsx @@ -198,20 +198,25 @@ export const UserLoginPage: React.FC = () => { ) : null} -
+ -
- - - or continue with - - + +
+
+ + 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 90d0cda1..d756d189 100644 --- a/src/packages/ce/src/user/pages/UserRegistrationPage.tsx +++ b/src/packages/ce/src/user/pages/UserRegistrationPage.tsx @@ -100,9 +100,13 @@ export const UserRegistrationPage: React.FC = () => { - - or sign up with - +
+
+ + or sign up with + +
+