diff --git a/.env.example b/.env.example index 207b5fe11..76a80bd0a 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,15 @@ BETTER_AUTH_URL=http://backend:3000 # Backend public base URL where /api/auth/* # COOKIE_DOMAIN=.yourdomain.com # Set in prod when frontend and backend share a parent domain # BETTER_AUTH_SECURE_COOKIES=false # Set to false when serving over plain http (defaults to true only for https:// BETTER_AUTH_URL); Secure cookies are dropped over http on non-localhost hosts +# Single sign-on via OIDC, e.g. Authentik (optional; see docs/self-hosting/configuration) +# Redirect URI to register in the IdP: /api/auth/oauth2/callback/authentik +# OIDC_ISSUER=https://auth.example.com/application/o/hive-pal/ +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= +# OIDC_BUTTON_LABEL=Sign in with Authentik +# OIDC_ALLOW_SIGNUP=true # create unknown users on first SSO login (users are matched by email) +# DISABLE_LOCAL_LOGIN=false # true = SSO only (password, magic link and passkeys disabled) + # API URLs API_URL=http://backend:3000 # Internal API URL (used by frontend container) # HiveScale was renamed to HiveHub. Prefer HIVEHUB_*; the legacy HIVESCALE_* diff --git a/apps/backend/.env.example b/apps/backend/.env.example index c55742f26..d42bd0d21 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -23,6 +23,21 @@ BETTER_AUTH_URL=http://localhost:3000 # COOKIE_DOMAIN=.hivepal.app # BETTER_AUTH_SECURE_COOKIES=false +# Single sign-on via OIDC, e.g. Authentik (optional) +# Enabled when OIDC_ISSUER (or OIDC_DISCOVERY_URL), OIDC_CLIENT_ID and +# OIDC_CLIENT_SECRET are set. Users are matched by email address. +# Redirect URI to register in the IdP: +# /api/auth/oauth2/callback/ +# OIDC_ISSUER=https://auth.example.com/application/o/hive-pal/ +# OIDC_DISCOVERY_URL= # overrides /.well-known/openid-configuration +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= +# OIDC_PROVIDER_ID=authentik +# OIDC_SCOPES="openid profile email" +# OIDC_BUTTON_LABEL="Sign in with Authentik" +# OIDC_ALLOW_SIGNUP=true # false = only existing users may sign in via SSO +# DISABLE_LOCAL_LOGIN=false # true = SSO only; ignored while OIDC isn't configured + # Email Configuration (Optional) # The system will automatically select the first configured provider: # 1. Resend (if RESEND_API_KEY is provided) diff --git a/apps/backend/src/auth/better-auth.ts b/apps/backend/src/auth/better-auth.ts index 5e65b5b8e..ed3e19d4d 100644 --- a/apps/backend/src/auth/better-auth.ts +++ b/apps/backend/src/auth/better-auth.ts @@ -1,8 +1,17 @@ import { betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; -import { magicLink, admin, customSession } from 'better-auth/plugins'; +import { + magicLink, + admin, + customSession, + genericOAuth, +} from 'better-auth/plugins'; import { passkey } from '@better-auth/passkey'; -import { createAuthMiddleware } from 'better-auth/api'; +import { + APIError, + createAuthMiddleware, + getSessionFromCtx, +} from 'better-auth/api'; import { hashPassword as scryptHash, verifyPassword as scryptVerify, @@ -12,6 +21,12 @@ import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../generated/prisma/client.js'; import { authDeps } from './auth-deps'; import { UserLoginEvent } from '../events/auth.events'; +import { + LOCAL_LOGIN_PATHS, + isLocalLoginDisabled, + loadOidcConfig, +} from './oidc-config'; +import { secureOidcAccountLink } from './oidc-linking'; const prismaForAuth = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), @@ -38,6 +53,17 @@ const useSecureCookies = ? process.env.BETTER_AUTH_SECURE_COOKIES.toLowerCase() === 'true' : (process.env.BETTER_AUTH_URL ?? '').startsWith('https://'); +const oidc = loadOidcConfig(); +const localLoginDisabled = isLocalLoginDisabled(process.env, oidc); +if ( + !oidc.enabled && + process.env.DISABLE_LOCAL_LOGIN?.toLowerCase() === 'true' +) { + console.warn( + '[auth] DISABLE_LOCAL_LOGIN is ignored because OIDC is not fully configured (OIDC_ISSUER/OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET).', + ); +} + export const auth = betterAuth({ database: prismaAdapter(prismaForAuth, { provider: 'postgresql' }), secret: process.env.BETTER_AUTH_SECRET, @@ -82,6 +108,19 @@ export const auth = betterAuth({ }, }, + account: { + accountLinking: { + enabled: true, + // The IdP is self-hosted and admin-controlled, so its email claim is + // trusted for matching even when it doesn't send email_verified=true. + trustedProviders: oidc.enabled ? [oidc.providerId] : [], + // Hive Pal has no email-verification step, so most local accounts are + // unverified. Linking them is allowed; secureOidcAccountLink() strips + // the local credentials of such accounts on an implicit link. + requireLocalEmailVerified: false, + }, + }, + session: { expiresIn: 60 * 60 * 24 * 7, cookieCache: { enabled: true, maxAge: 60 * 5 }, @@ -108,6 +147,17 @@ export const auth = betterAuth({ }, hooks: { + // Must stay async: Better Auth only honours errors from a rejected promise. + // eslint-disable-next-line @typescript-eslint/require-await + before: createAuthMiddleware(async (ctx) => { + if (localLoginDisabled && LOCAL_LOGIN_PATHS.has(ctx.path)) { + // Better Auth's APIError is how hooks reject a request. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw new APIError('FORBIDDEN', { + message: 'Local login is disabled. Please sign in with SSO.', + }); + } + }), after: createAuthMiddleware(async (ctx) => { // Lazy bcrypt → scrypt rehash on successful email sign-in if ( @@ -158,6 +208,30 @@ export const auth = betterAuth({ }, databaseHooks: { + account: { + create: { + after: async (account, ctx) => { + if (!oidc.enabled) return; + await secureOidcAccountLink({ + prisma: authDeps.prisma, + providerId: oidc.providerId, + account, + isLinkedBySignedInOwner: async () => { + // Manual linking (settings page) runs the OAuth callback with + // the owner's session cookie; an implicit link at sign-in + // has no session for this user. + if (!ctx) return false; + try { + const session = await getSessionFromCtx(ctx); + return session?.user.id === account.userId; + } catch { + return false; + } + }, + }); + }, + }, + }, user: { create: { after: async (user) => { @@ -220,6 +294,27 @@ export const auth = betterAuth({ rpName: 'Hive Pal', origin: process.env.FRONTEND_URL ?? 'http://localhost:5173', }), + ...(oidc.enabled + ? [ + genericOAuth({ + config: [ + { + providerId: oidc.providerId, + discoveryUrl: oidc.discoveryUrl, + clientId: oidc.clientId, + clientSecret: oidc.clientSecret, + scopes: oidc.scopes, + pkce: true, + disableSignUp: !oidc.allowSignup, + mapProfileToUser: (profile) => ({ + name: + profile.name || profile.preferred_username || profile.email, + }), + }, + ], + }), + ] + : []), admin({ defaultRole: 'USER', adminRoles: ['ADMIN'], @@ -227,13 +322,28 @@ export const auth = betterAuth({ customSession(async ({ user, session }) => { const dbUser = await authDeps.prisma.user.findUnique({ where: { id: user.id }, - select: { passwordChangeRequired: true, role: true }, + select: { + passwordChangeRequired: true, + role: true, + privacyPolicyConsent: true, + accounts: { select: { providerId: true } }, + }, }); + const providers = new Set(dbUser?.accounts.map((a) => a.providerId)); + // Accounts created through SSO never went through the signup form, so + // they're asked for privacy-policy consent once after the first login. + const consentRequired = + oidc.enabled && + !!dbUser && + !dbUser.privacyPolicyConsent && + providers.has(oidc.providerId) && + !providers.has('credential'); return { user: { ...user, role: dbUser?.role ?? 'USER', passwordChangeRequired: dbUser?.passwordChangeRequired ?? false, + consentRequired, }, session, }; diff --git a/apps/backend/src/auth/oidc-config.spec.ts b/apps/backend/src/auth/oidc-config.spec.ts new file mode 100644 index 000000000..d8e69ccde --- /dev/null +++ b/apps/backend/src/auth/oidc-config.spec.ts @@ -0,0 +1,95 @@ +import { + buildDiscoveryUrl, + isLocalLoginDisabled, + loadOidcConfig, +} from './oidc-config'; +import { buildEnvConfig } from '../env.controller'; + +const authentikEnv = { + OIDC_ISSUER: 'https://auth.example.com/application/o/hive-pal/', + OIDC_CLIENT_ID: 'client', + OIDC_CLIENT_SECRET: 'secret', +}; + +describe('loadOidcConfig', () => { + it('is disabled without configuration', () => { + expect(loadOidcConfig({}).enabled).toBe(false); + }); + + it('is disabled when the client secret is missing', () => { + expect( + loadOidcConfig({ ...authentikEnv, OIDC_CLIENT_SECRET: '' }).enabled, + ).toBe(false); + }); + + it('derives the discovery URL from the Authentik issuer', () => { + const config = loadOidcConfig(authentikEnv); + expect(config.enabled).toBe(true); + expect(config.discoveryUrl).toBe( + 'https://auth.example.com/application/o/hive-pal/.well-known/openid-configuration', + ); + expect(config.providerId).toBe('authentik'); + expect(config.scopes).toEqual(['openid', 'profile', 'email']); + expect(config.allowSignup).toBe(true); + }); + + it('prefers an explicit discovery URL and parses options', () => { + const config = loadOidcConfig({ + ...authentikEnv, + OIDC_DISCOVERY_URL: 'https://idp/.well-known/openid-configuration', + OIDC_SCOPES: 'openid,email', + OIDC_ALLOW_SIGNUP: 'false', + OIDC_BUTTON_LABEL: 'Login with Authentik', + }); + expect(config.discoveryUrl).toBe( + 'https://idp/.well-known/openid-configuration', + ); + expect(config.scopes).toEqual(['openid', 'email']); + expect(config.allowSignup).toBe(false); + expect(config.buttonLabel).toBe('Login with Authentik'); + }); + + it('strips trailing slashes when building the discovery URL', () => { + expect(buildDiscoveryUrl('https://idp/app//')).toBe( + 'https://idp/app/.well-known/openid-configuration', + ); + }); +}); + +describe('isLocalLoginDisabled', () => { + it('is ignored while OIDC is not configured', () => { + expect(isLocalLoginDisabled({ DISABLE_LOCAL_LOGIN: 'true' })).toBe(false); + }); + + it('applies when OIDC is configured', () => { + expect( + isLocalLoginDisabled({ ...authentikEnv, DISABLE_LOCAL_LOGIN: 'true' }), + ).toBe(true); + expect(isLocalLoginDisabled(authentikEnv)).toBe(false); + }); +}); + +describe('buildEnvConfig SSO flags', () => { + it('exposes SSO settings without secrets', () => { + const config = buildEnvConfig({ + ...authentikEnv, + OIDC_ALLOW_SIGNUP: 'false', + }); + expect(config).toMatchObject({ + VITE_OIDC_ENABLED: 'true', + VITE_OIDC_PROVIDER_ID: 'authentik', + VITE_OIDC_BUTTON_LABEL: 'Sign in with SSO', + VITE_OIDC_ALLOW_SIGNUP: 'false', + VITE_LOCAL_LOGIN_ENABLED: 'true', + }); + expect(JSON.stringify(config)).not.toContain('secret'); + }); + + it('reports SSO as disabled by default', () => { + expect(buildEnvConfig({})).toMatchObject({ + VITE_OIDC_ENABLED: 'false', + VITE_OIDC_ALLOW_SIGNUP: 'false', + VITE_LOCAL_LOGIN_ENABLED: 'true', + }); + }); +}); diff --git a/apps/backend/src/auth/oidc-config.ts b/apps/backend/src/auth/oidc-config.ts new file mode 100644 index 000000000..6e6094c55 --- /dev/null +++ b/apps/backend/src/auth/oidc-config.ts @@ -0,0 +1,79 @@ +// OIDC single sign-on (e.g. Authentik) configured entirely via environment +// variables. Users are matched to existing Hive Pal accounts by email. +// +// OIDC_ISSUER / OIDC_DISCOVERY_URL one of them is required to enable SSO. +// Authentik: OIDC_ISSUER=https://auth.example.com/application/o// +// OIDC_CLIENT_ID, OIDC_CLIENT_SECRET required +// OIDC_PROVIDER_ID provider id used in the callback URL (default: authentik) +// → redirect URI: /api/auth/oauth2/callback/ +// OIDC_SCOPES space/comma separated (default: "openid profile email") +// OIDC_BUTTON_LABEL login button text (default: "Sign in with SSO") +// OIDC_ALLOW_SIGNUP create unknown users on first SSO login (default: true) +// DISABLE_LOCAL_LOGIN disable password, magic-link and passkey sign-in and +// email sign-up (default: false; only honoured while +// SSO is configured, to avoid locking everyone out) + +export interface OidcConfig { + enabled: boolean; + providerId: string; + discoveryUrl: string; + clientId: string; + clientSecret: string; + scopes: string[]; + buttonLabel: string; + allowSignup: boolean; +} + +const parseBool = (value: string | undefined, fallback: boolean): boolean => { + if (value === undefined || value.trim() === '') return fallback; + return ['true', '1', 'yes', 'on'].includes(value.trim().toLowerCase()); +}; + +export function buildDiscoveryUrl(issuer: string): string { + return `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`; +} + +export function loadOidcConfig( + env: NodeJS.ProcessEnv = process.env, +): OidcConfig { + const issuer = env.OIDC_ISSUER?.trim() ?? ''; + const discoveryUrl = + env.OIDC_DISCOVERY_URL?.trim() || (issuer ? buildDiscoveryUrl(issuer) : ''); + const clientId = env.OIDC_CLIENT_ID?.trim() ?? ''; + const clientSecret = env.OIDC_CLIENT_SECRET?.trim() ?? ''; + const scopes = (env.OIDC_SCOPES?.trim() || 'openid profile email') + .split(/[\s,]+/) + .filter(Boolean); + + return { + enabled: !!(discoveryUrl && clientId && clientSecret), + providerId: env.OIDC_PROVIDER_ID?.trim() || 'authentik', + discoveryUrl, + clientId, + clientSecret, + scopes, + buttonLabel: env.OIDC_BUTTON_LABEL?.trim() || 'Sign in with SSO', + allowSignup: parseBool(env.OIDC_ALLOW_SIGNUP, true), + }; +} + +export function isLocalLoginDisabled( + env: NodeJS.ProcessEnv = process.env, + oidc: OidcConfig = loadOidcConfig(env), +): boolean { + return oidc.enabled && parseBool(env.DISABLE_LOCAL_LOGIN, false); +} + +// Better Auth endpoint paths (route templates, as seen in `ctx.path`) that make +// up "local" login. Blocked when DISABLE_LOCAL_LOGIN is active. +export const LOCAL_LOGIN_PATHS = new Set([ + '/sign-in/email', + '/sign-up/email', + '/sign-in/magic-link', + '/magic-link/verify', + '/request-password-reset', + '/reset-password', + '/reset-password/:token', + '/passkey/generate-authenticate-options', + '/passkey/verify-authentication', +]); diff --git a/apps/backend/src/auth/oidc-linking.spec.ts b/apps/backend/src/auth/oidc-linking.spec.ts new file mode 100644 index 000000000..767bf17f6 --- /dev/null +++ b/apps/backend/src/auth/oidc-linking.spec.ts @@ -0,0 +1,83 @@ +import { vi } from 'vitest'; +import { secureOidcAccountLink } from './oidc-linking'; + +const makePrisma = (emailVerified: boolean | null) => ({ + user: { + findUnique: vi.fn(() => + Promise.resolve(emailVerified === null ? null : { emailVerified }), + ), + update: vi.fn(() => Promise.resolve({})), + }, + account: { deleteMany: vi.fn(() => Promise.resolve({ count: 1 })) }, + passkey: { deleteMany: vi.fn(() => Promise.resolve({ count: 0 })) }, + session: { deleteMany: vi.fn(() => Promise.resolve({ count: 2 })) }, +}); + +const linkedAt = new Date('2026-01-01T00:00:00Z'); +const account = { userId: 'u1', providerId: 'authentik', createdAt: linkedAt }; + +describe('secureOidcAccountLink', () => { + it('ignores accounts of other providers', async () => { + const prisma = makePrisma(false); + await secureOidcAccountLink({ + prisma, + providerId: 'authentik', + account: { ...account, providerId: 'credential' }, + isLinkedBySignedInOwner: () => Promise.resolve(false), + }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it('leaves verified accounts untouched', async () => { + const prisma = makePrisma(true); + const owner = vi.fn(() => Promise.resolve(false)); + await secureOidcAccountLink({ + prisma, + providerId: 'authentik', + account, + isLinkedBySignedInOwner: owner, + }); + expect(owner).not.toHaveBeenCalled(); + expect(prisma.account.deleteMany).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('strips local credentials on an implicit link to an unverified account', async () => { + const prisma = makePrisma(false); + await secureOidcAccountLink({ + prisma, + providerId: 'authentik', + account, + isLinkedBySignedInOwner: () => Promise.resolve(false), + }); + expect(prisma.account.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'u1', providerId: 'credential' }, + }); + expect(prisma.passkey.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'u1' }, + }); + expect(prisma.session.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'u1', createdAt: { lt: linkedAt } }, + }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'u1' }, + data: { emailVerified: true }, + }); + }); + + it('keeps credentials when the signed-in owner links the account', async () => { + const prisma = makePrisma(false); + await secureOidcAccountLink({ + prisma, + providerId: 'authentik', + account, + isLinkedBySignedInOwner: () => Promise.resolve(true), + }); + expect(prisma.account.deleteMany).not.toHaveBeenCalled(); + expect(prisma.session.deleteMany).not.toHaveBeenCalled(); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'u1' }, + data: { emailVerified: true }, + }); + }); +}); diff --git a/apps/backend/src/auth/oidc-linking.ts b/apps/backend/src/auth/oidc-linking.ts new file mode 100644 index 000000000..1cf088430 --- /dev/null +++ b/apps/backend/src/auth/oidc-linking.ts @@ -0,0 +1,87 @@ +import { Logger } from '@nestjs/common'; + +const logger = new Logger('OidcLinking'); + +// Minimal slice of the Prisma client used here, so this stays unit-testable. +export interface OidcLinkingPrisma { + user: { + findUnique(args: { + where: { id: string }; + select: { emailVerified: true }; + }): Promise<{ emailVerified: boolean } | null>; + update(args: { + where: { id: string }; + data: { emailVerified: boolean }; + }): Promise; + }; + account: { + deleteMany(args: { + where: { userId: string; providerId: string }; + }): Promise<{ count: number }>; + }; + passkey: { + deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>; + }; + session: { + deleteMany(args: { + where: { userId: string; createdAt: { lt: Date } }; + }): Promise<{ count: number }>; + }; +} + +/** + * Runs after an account row is created. When the OIDC provider gets attached + * to a user whose email was never verified locally, the IdP has just proven + * ownership of that address, so: + * + * - if the link happened implicitly during an SSO sign-in (matched by email), + * the local password, passkeys and sessions are removed first. Otherwise + * someone could pre-register a password account with a colleague's address + * and keep access after the colleague's first SSO login ("pre-hijack"). + * The real owner can still set a new password via "forgot password". + * - if the signed-in owner linked the account from their settings, nothing is + * removed — they already proved control of the local account. + * + * In both cases the email is then marked as verified. + */ +export async function secureOidcAccountLink(params: { + prisma: OidcLinkingPrisma; + providerId: string; + account: { userId: string; providerId: string; createdAt: Date }; + /** Resolves whether the request is made by the already signed-in owner. */ + isLinkedBySignedInOwner: () => Promise; +}): Promise { + const { prisma, providerId, account } = params; + if (account.providerId !== providerId) return; + + const user = await prisma.user.findUnique({ + where: { id: account.userId }, + select: { emailVerified: true }, + }); + if (!user || user.emailVerified) return; + + if (!(await params.isLinkedBySignedInOwner())) { + const userId = account.userId; + const [credentials, passkeys, sessions] = await Promise.all([ + prisma.account.deleteMany({ + where: { userId, providerId: 'credential' }, + }), + prisma.passkey.deleteMany({ where: { userId } }), + // Database after-hooks run once the request completes, i.e. after the + // SSO session was created. Only revoke sessions that predate the link. + prisma.session.deleteMany({ + where: { userId, createdAt: { lt: account.createdAt } }, + }), + ]); + if (credentials.count + passkeys.count + sessions.count > 0) { + logger.warn( + `SSO linked to unverified account ${userId}: removed ${credentials.count} password credential(s), ${passkeys.count} passkey(s), ${sessions.count} session(s)`, + ); + } + } + + await prisma.user.update({ + where: { id: account.userId }, + data: { emailVerified: true }, + }); +} diff --git a/apps/backend/src/env.controller.ts b/apps/backend/src/env.controller.ts index d93a5cfa2..2644749f4 100644 --- a/apps/backend/src/env.controller.ts +++ b/apps/backend/src/env.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Res } from '@nestjs/common'; import type { Response } from 'express'; +import { isLocalLoginDisabled, loadOidcConfig } from './auth/oidc-config'; @Controller() export class EnvController { @@ -16,15 +17,27 @@ export interface EnvConfig { VITE_SENTRY_ENVIRONMENT: string; VITE_FARO_URL: string; VITE_FARO_ENVIRONMENT: string; + // SSO settings derived from the backend's OIDC_* env vars (never secrets). + VITE_OIDC_ENABLED: string; + VITE_OIDC_PROVIDER_ID: string; + VITE_OIDC_BUTTON_LABEL: string; + VITE_OIDC_ALLOW_SIGNUP: string; + VITE_LOCAL_LOGIN_ENABLED: string; } export function buildEnvConfig( env: NodeJS.ProcessEnv = process.env, ): EnvConfig { + const oidc = loadOidcConfig(env); return { VITE_SENTRY_DSN: env.VITE_SENTRY_DSN || '', VITE_SENTRY_ENVIRONMENT: env.VITE_SENTRY_ENVIRONMENT || '', VITE_FARO_URL: env.VITE_FARO_URL || '', VITE_FARO_ENVIRONMENT: env.VITE_FARO_ENVIRONMENT || '', + VITE_OIDC_ENABLED: String(oidc.enabled), + VITE_OIDC_PROVIDER_ID: oidc.enabled ? oidc.providerId : '', + VITE_OIDC_BUTTON_LABEL: oidc.enabled ? oidc.buttonLabel : '', + VITE_OIDC_ALLOW_SIGNUP: String(oidc.enabled && oidc.allowSignup), + VITE_LOCAL_LOGIN_ENABLED: String(!isLocalLoginDisabled(env, oidc)), }; } diff --git a/apps/frontend/public/locales/de/auth.json b/apps/frontend/public/locales/de/auth.json index ce8e34f66..f41f73dea 100644 --- a/apps/frontend/public/locales/de/auth.json +++ b/apps/frontend/public/locales/de/auth.json @@ -69,5 +69,27 @@ "passwordMismatch": "Passwörter stimmen nicht überein", "passwordTooShort": "Das Passwort muss mindestens 6 Zeichen lang sein", "error": "Beim Zurücksetzen des Passworts ist ein Fehler aufgetreten" + }, + "sso": { + "signIn": "Mit SSO anmelden", + "signUp": "Mit SSO registrieren", + "or": "oder", + "redirecting": "Weiterleitung…", + "registrationClosed": "Die Registrierung ist geschlossen. Bitte wenden Sie sich an Ihren Administrator.", + "errors": { + "generic": "Die Anmeldung per SSO ist fehlgeschlagen. Bitte versuchen Sie es erneut.", + "signupDisabled": "Für diese E-Mail-Adresse gibt es kein Hive-Pal-Konto und die Registrierung per SSO ist deaktiviert. Bitte wenden Sie sich an Ihren Administrator.", + "notLinked": "Ihr SSO-Konto konnte nicht mit Ihrem bestehenden Hive-Pal-Konto verknüpft werden.", + "emailMismatch": "Das SSO-Konto verwendet eine andere E-Mail-Adresse als Ihr Hive-Pal-Konto.", + "alreadyLinked": "Dieses SSO-Konto ist bereits mit einem anderen Hive-Pal-Benutzer verknüpft.", + "emailMissing": "Der Identitätsanbieter hat keine E-Mail-Adresse übermittelt." + } + }, + "consent": { + "title": "Datenschutzerklärung", + "description": "Bevor Sie fortfahren, lesen und akzeptieren Sie bitte unsere Datenschutzerklärung.", + "submit": "Weiter", + "logout": "Abmelden", + "saveFailed": "Ihre Zustimmung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut." } } diff --git a/apps/frontend/public/locales/de/common.json b/apps/frontend/public/locales/de/common.json index c747520ee..396482041 100644 --- a/apps/frontend/public/locales/de/common.json +++ b/apps/frontend/public/locales/de/common.json @@ -287,6 +287,14 @@ "weatherForecast": "Wettervorhersage" }, "settings": { + "sso": "Single Sign-On (SSO)", + "ssoDescription": "Mit Ihrem Organisationskonto anmelden", + "ssoLinked": "Ihr Konto ist mit SSO verknüpft.", + "ssoNotLinked": "Ihr Konto ist noch nicht mit SSO verknüpft.", + "ssoLink": "SSO-Konto verknüpfen", + "ssoUnlink": "Verknüpfung aufheben", + "ssoLinkFailed": "Ihr SSO-Konto konnte nicht verknüpft werden", + "ssoUnlinkFailed": "Die Verknüpfung Ihres SSO-Kontos konnte nicht aufgehoben werden", "managePreferences": "Verwalten Sie Ihre Konto- und Anwendungseinstellungen", "generalSettings": "Allgemeine Einstellungen", "configureLanguageRegional": "Sprache und regionale Einstellungen konfigurieren", diff --git a/apps/frontend/public/locales/en/auth.json b/apps/frontend/public/locales/en/auth.json index ca0266a3a..7e7d22c98 100644 --- a/apps/frontend/public/locales/en/auth.json +++ b/apps/frontend/public/locales/en/auth.json @@ -69,5 +69,27 @@ "passwordMismatch": "Passwords do not match", "passwordTooShort": "Password must be at least 6 characters", "error": "An error occurred while resetting your password" + }, + "sso": { + "signIn": "Sign in with SSO", + "signUp": "Sign up with SSO", + "or": "or", + "redirecting": "Redirecting…", + "registrationClosed": "Registration is closed. Please contact your administrator.", + "errors": { + "generic": "Single sign-on failed. Please try again.", + "signupDisabled": "There is no Hive Pal account for this email address, and sign-up via SSO is disabled. Please contact your administrator.", + "notLinked": "Your SSO account could not be linked to your existing Hive Pal account.", + "emailMismatch": "The SSO account uses a different email address than your Hive Pal account.", + "alreadyLinked": "This SSO account is already linked to another Hive Pal user.", + "emailMissing": "The identity provider did not send an email address." + } + }, + "consent": { + "title": "Privacy Policy", + "description": "Before you continue, please review and accept our privacy policy.", + "submit": "Continue", + "logout": "Logout", + "saveFailed": "Could not save your consent. Please try again." } } \ No newline at end of file diff --git a/apps/frontend/public/locales/en/common.json b/apps/frontend/public/locales/en/common.json index 9cad7cbe9..f56797c0d 100644 --- a/apps/frontend/public/locales/en/common.json +++ b/apps/frontend/public/locales/en/common.json @@ -147,6 +147,14 @@ "weatherForecast": "Weather Forecast" }, "settings": { + "sso": "Single sign-on (SSO)", + "ssoDescription": "Sign in with your organization account", + "ssoLinked": "Your account is linked to SSO.", + "ssoNotLinked": "Your account is not linked to SSO yet.", + "ssoLink": "Link SSO account", + "ssoUnlink": "Unlink", + "ssoLinkFailed": "Could not link your SSO account", + "ssoUnlinkFailed": "Could not unlink your SSO account", "managePreferences": "Manage your account and application preferences", "generalSettings": "General Settings", "configureLanguageRegional": "Configure your language and regional preferences", diff --git a/apps/frontend/src/api/hooks/useAuth.ts b/apps/frontend/src/api/hooks/useAuth.ts index 8a29077a6..df826f9a4 100644 --- a/apps/frontend/src/api/hooks/useAuth.ts +++ b/apps/frontend/src/api/hooks/useAuth.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { authClient, useSession } from '@/lib/auth-client'; import { logApiError } from '../errorLogger'; @@ -74,3 +74,32 @@ export const useAuth = () => ({ changePassword: useChangePassword(), profile: useUserProfile(), }); + +export const LINKED_ACCOUNTS_QUERY_KEY = ['auth', 'linked-accounts'] as const; + +/** Sign-in methods linked to the current user ('credential', 'authentik', …). */ +export const useLinkedAccounts = () => + useQuery({ + queryKey: LINKED_ACCOUNTS_QUERY_KEY, + queryFn: async () => { + const result = await authClient.listAccounts(); + if (result.error) throw result.error; + return (result.data ?? []).map(account => account.providerId); + }, + }); + +export const useUnlinkAccount = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (providerId: string) => { + const result = await authClient.unlinkAccount({ providerId }); + if (result.error) throw result.error; + return result.data; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: LINKED_ACCOUNTS_QUERY_KEY }), + onError: error => { + logApiError(error, '/api/auth/unlink-account', 'POST'); + }, + }); +}; diff --git a/apps/frontend/src/components/auth/sso-account-card.tsx b/apps/frontend/src/components/auth/sso-account-card.tsx new file mode 100644 index 000000000..463a3406c --- /dev/null +++ b/apps/frontend/src/components/auth/sso-account-card.tsx @@ -0,0 +1,116 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { Link2, Loader2, Unlink } from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Alert } from '@/components/ui/alert'; +import { authClient } from '@/lib/auth-client'; +import { getAuthConfig } from '@/lib/auth-config'; +import { useLinkedAccounts, useUnlinkAccount } from '@/api/hooks/useAuth'; +import { useSsoErrorMessage } from './use-sso-error-message'; + +/** Lets a signed-in user link or unlink their SSO (OIDC) identity. */ +export const SsoAccountCard: React.FC = () => { + const { t } = useTranslation('common'); + const { oidcProviderId, localLoginEnabled } = getAuthConfig(); + const { data: providers, isLoading } = useLinkedAccounts(); + const unlink = useUnlinkAccount(); + const [searchParams] = useSearchParams(); + const linkError = useSsoErrorMessage(searchParams.get('error')); + const [linking, setLinking] = useState(false); + const [error, setError] = useState(null); + + const isLinked = !!providers?.includes(oidcProviderId); + // Unlinking the only sign-in method (or SSO while local login is disabled) + // would lock the user out. + const canUnlink = + isLinked && localLoginEnabled && (providers?.length ?? 0) > 1; + + const handleLink = async () => { + setError(null); + setLinking(true); + const settingsUrl = new URL('/settings', window.location.origin).href; + try { + // On success the browser is redirected to the identity provider. + const result = await authClient.oauth2.link({ + providerId: oidcProviderId, + callbackURL: settingsUrl, + errorCallbackURL: settingsUrl, + }); + if (result.error) { + setError(result.error.message ?? t('settings.ssoLinkFailed')); + setLinking(false); + } + } catch (err) { + console.error(err); + setError(t('settings.ssoLinkFailed')); + setLinking(false); + } + }; + + const handleUnlink = async () => { + setError(null); + try { + await unlink.mutateAsync(oidcProviderId); + } catch (err) { + console.error(err); + setError(t('settings.ssoUnlinkFailed')); + } + }; + + return ( + + + + + {t('settings.sso')} + + {t('settings.ssoDescription')} + + + {(error || linkError) && ( + {error || linkError} + )} +
+

+ {isLoading + ? '…' + : isLinked + ? t('settings.ssoLinked') + : t('settings.ssoNotLinked')} +

+ {!isLoading && !isLinked && ( + + )} + {canUnlink && ( + + )} +
+
+
+ ); +}; + +export default SsoAccountCard; diff --git a/apps/frontend/src/components/auth/sso-button.tsx b/apps/frontend/src/components/auth/sso-button.tsx new file mode 100644 index 000000000..bc25c8563 --- /dev/null +++ b/apps/frontend/src/components/auth/sso-button.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LogIn } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { authClient } from '@/lib/auth-client'; +import { getAuthConfig } from '@/lib/auth-config'; +import { cn } from '@/lib/utils'; + +interface SsoButtonProps { + /** Path to land on after a successful sign-in. */ + redirectTo?: string; + /** Page to return to with `?error=…` when SSO fails. */ + errorPath: string; + mode?: 'signIn' | 'signUp'; + className?: string; +} + +/** Absolute URL on the frontend origin (the auth callback runs on the backend). */ +const toFrontendUrl = (path: string) => + new URL(path, window.location.origin).href; + +export const SsoButton = ({ + redirectTo = '/', + errorPath, + mode = 'signIn', + className, +}: SsoButtonProps) => { + const { t } = useTranslation('auth'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const { oidcProviderId, oidcButtonLabel } = getAuthConfig(); + + const handleClick = async () => { + setBusy(true); + setError(''); + try { + // On success the browser is redirected to the identity provider. + const result = await authClient.signIn.oauth2({ + providerId: oidcProviderId, + callbackURL: toFrontendUrl(redirectTo), + errorCallbackURL: toFrontendUrl(errorPath), + }); + if (result.error) { + setError(result.error.message ?? t('sso.errors.generic')); + setBusy(false); + } + } catch (err) { + console.error('SSO error:', err); + setError(t('sso.errors.generic')); + setBusy(false); + } + }; + + const label = + mode === 'signUp' ? t('sso.signUp') : oidcButtonLabel || t('sso.signIn'); + + return ( +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +}; + +export const SsoDivider = ({ label }: { label: string }) => ( +
+
+ +
+
+ {label} +
+
+); diff --git a/apps/frontend/src/components/auth/use-sso-error-message.ts b/apps/frontend/src/components/auth/use-sso-error-message.ts new file mode 100644 index 000000000..e9173d26e --- /dev/null +++ b/apps/frontend/src/components/auth/use-sso-error-message.ts @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; + +/** Maps the `?error=` code Better Auth appends after a failed SSO round-trip. */ +export const useSsoErrorMessage = (code: string | null): string => { + const { t } = useTranslation('auth'); + if (!code) return ''; + switch (code) { + case 'signup_disabled': + return t('sso.errors.signupDisabled'); + case 'account_not_linked': + case 'unable_to_link_account': + return t('sso.errors.notLinked'); + case "email_doesn't_match": + return t('sso.errors.emailMismatch'); + case 'account_already_linked_to_different_user': + return t('sso.errors.alreadyLinked'); + case 'email_is_missing': + return t('sso.errors.emailMissing'); + default: + return t('sso.errors.generic'); + } +}; diff --git a/apps/frontend/src/context/auth-context/auth-context.ts b/apps/frontend/src/context/auth-context/auth-context.ts index f90d9b35c..904befd7e 100644 --- a/apps/frontend/src/context/auth-context/auth-context.ts +++ b/apps/frontend/src/context/auth-context/auth-context.ts @@ -6,6 +6,7 @@ export interface AuthUser { name?: string | null; role?: string | null; passwordChangeRequired?: boolean; + consentRequired?: boolean; } interface AuthContextType { diff --git a/apps/frontend/src/context/auth-context/auth-provider.tsx b/apps/frontend/src/context/auth-context/auth-provider.tsx index 5ab5be4e8..21053969d 100644 --- a/apps/frontend/src/context/auth-context/auth-provider.tsx +++ b/apps/frontend/src/context/auth-context/auth-provider.tsx @@ -39,6 +39,19 @@ export const AuthProvider: React.FC = ({ children }) => { } }, [user?.passwordChangeRequired]); + // SSO-created accounts must accept the privacy policy once before using the app + useEffect(() => { + const path = window.location.pathname; + if ( + user?.consentRequired && + !user.passwordChangeRequired && + path !== '/account/consent' && + !path.endsWith('/privacy-policy') + ) { + window.location.href = '/account/consent'; + } + }, [user?.consentRequired, user?.passwordChangeRequired]); + const login = useCallback( async (username: string, password: string, from = '/') => { const result = await authClient.signIn.email({ diff --git a/apps/frontend/src/lib/auth-client.ts b/apps/frontend/src/lib/auth-client.ts index 8da85b588..c5c19c7ce 100644 --- a/apps/frontend/src/lib/auth-client.ts +++ b/apps/frontend/src/lib/auth-client.ts @@ -4,6 +4,7 @@ import { adminClient, customSessionClient, inferAdditionalFields, + genericOAuthClient, } from 'better-auth/client/plugins'; import { passkeyClient } from '@better-auth/passkey/client'; @@ -12,12 +13,14 @@ export const authClient = createAuthClient({ magicLinkClient(), passkeyClient(), adminClient(), + genericOAuthClient(), inferAdditionalFields({ user: { role: { type: 'string' }, passwordChangeRequired: { type: 'boolean' }, privacyPolicyConsent: { type: 'boolean' }, newsletterConsent: { type: 'boolean' }, + consentRequired: { type: 'boolean' }, }, }), customSessionClient(), diff --git a/apps/frontend/src/lib/auth-config.ts b/apps/frontend/src/lib/auth-config.ts new file mode 100644 index 000000000..5eab85518 --- /dev/null +++ b/apps/frontend/src/lib/auth-config.ts @@ -0,0 +1,22 @@ +// SSO / login-method settings, provided at runtime by the backend via /env.js +// (derived from its OIDC_* env vars) with a build-time VITE_* fallback. +const readEnv = (key: string): string | undefined => + (typeof window !== 'undefined' ? window.ENV?.[key] : undefined) || + (import.meta.env[key] as string | undefined); + +const readBool = (key: string, fallback: boolean): boolean => { + const value = readEnv(key); + return value === undefined || value === '' ? fallback : value === 'true'; +}; + +export const getAuthConfig = () => { + const oidcEnabled = readBool('VITE_OIDC_ENABLED', false); + return { + oidcEnabled, + oidcProviderId: readEnv('VITE_OIDC_PROVIDER_ID') || 'authentik', + oidcButtonLabel: readEnv('VITE_OIDC_BUTTON_LABEL') || '', + oidcAllowSignup: oidcEnabled && readBool('VITE_OIDC_ALLOW_SIGNUP', true), + localLoginEnabled: + !oidcEnabled || readBool('VITE_LOCAL_LOGIN_ENABLED', true), + }; +}; diff --git a/apps/frontend/src/pages/account/index.ts b/apps/frontend/src/pages/account/index.ts index 3252967a5..e962b465d 100644 --- a/apps/frontend/src/pages/account/index.ts +++ b/apps/frontend/src/pages/account/index.ts @@ -1 +1,2 @@ export { default as ChangePasswordPage } from './change-password-page'; +export { default as PrivacyConsentPage } from './privacy-consent-page'; diff --git a/apps/frontend/src/pages/account/privacy-consent-page.tsx b/apps/frontend/src/pages/account/privacy-consent-page.tsx new file mode 100644 index 000000000..5474a7c84 --- /dev/null +++ b/apps/frontend/src/pages/account/privacy-consent-page.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '@/context/auth-context'; +import { authClient } from '@/lib/auth-client'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { Alert } from '@/components/ui/alert'; + +/** + * Shown once to users whose account was created through SSO: they skipped the + * signup form, so privacy-policy consent is collected here instead. + */ +const PrivacyConsentPage: React.FC = () => { + const { t } = useTranslation('auth'); + const { logout } = useAuth(); + const [privacyPolicyConsent, setPrivacyPolicyConsent] = useState(false); + const [newsletterConsent, setNewsletterConsent] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!privacyPolicyConsent) { + setError(t('register.consent.privacyRequired')); + return; + } + setError(null); + setSaving(true); + try { + const now = new Date(); + const result = await authClient.updateUser({ + privacyPolicyConsent: true, + privacyConsentTimestamp: now, + newsletterConsent, + newsletterConsentTimestamp: newsletterConsent ? now : undefined, + } as never); + if (result.error) { + setError(result.error.message ?? t('consent.saveFailed')); + setSaving(false); + return; + } + // Full reload so the session (and its consentRequired flag) is refetched. + window.location.href = '/'; + } catch (err) { + console.error('Consent error:', err); + setError(t('consent.saveFailed')); + setSaving(false); + } + }; + + return ( +
+ + + {t('consent.title')} + {t('consent.description')} + + + {error && ( + + {error} + + )} +
+
+ + setPrivacyPolicyConsent(checked as boolean) + } + className="mt-1" + /> + +
+
+ + setNewsletterConsent(checked as boolean) + } + className="mt-1" + /> + +
+ +
+
+ + + +
+
+ ); +}; + +export default PrivacyConsentPage; diff --git a/apps/frontend/src/pages/login-page.tsx b/apps/frontend/src/pages/login-page.tsx index 6865d9491..da560756a 100644 --- a/apps/frontend/src/pages/login-page.tsx +++ b/apps/frontend/src/pages/login-page.tsx @@ -1,13 +1,21 @@ import { FormEvent, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { useAuth } from '@/context/auth-context'; -import { useNavigate, Link, useLocation } from 'react-router-dom'; +import { + useNavigate, + Link, + useLocation, + useSearchParams, +} from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { PublicFooter } from '@/components/layout/public-footer'; import { authClient } from '@/lib/auth-client'; +import { getAuthConfig } from '@/lib/auth-config'; +import { SsoButton, SsoDivider } from '@/components/auth/sso-button'; +import { useSsoErrorMessage } from '@/components/auth/use-sso-error-message'; const LoginPage = () => { const [username, setUsername] = useState(''); @@ -22,6 +30,10 @@ const LoginPage = () => { const navigate = useNavigate(); const location = useLocation(); const { login, isLoggedIn } = useAuth(); + const [searchParams] = useSearchParams(); + const ssoError = useSsoErrorMessage(searchParams.get('error')); + const { oidcEnabled, oidcAllowSignup, localLoginEnabled } = getAuthConfig(); + const showSignUpLink = localLoginEnabled || oidcAllowSignup; // Get the redirect path from location state, default to '/' const from = location.state?.from?.pathname || '/'; @@ -111,114 +123,139 @@ const LoginPage = () => {
-
-
- -
- setUsername(e.target.value)} - placeholder={t('login.email')} - className="bg-white/90 border-white/30 placeholder:text-gray-500" - /> -
+ {ssoError && ( +
+ {ssoError}
+ )} -
-
- - - {t('login.forgotPassword')} - -
-
- setPassword(e.target.value)} - className="bg-white/90 border-white/30 placeholder:text-gray-500" - /> -
+ {oidcEnabled && ( +
+ + {localLoginEnabled && }
+ )} - {error && ( -
- {error} -
- )} - -
- -
- + {localLoginEnabled && ( + <> +
+
+ +
+ setUsername(e.target.value)} + placeholder={t('login.email')} + className="bg-white/90 border-white/30 placeholder:text-gray-500" + /> +
+
- {magicLinkSent ? ( -
- Magic link sent — check your email for a sign-in link. -
- ) : ( -
-
-
- +
+
+ + + {t('login.forgotPassword')} + +
+
+ setPassword(e.target.value)} + className="bg-white/90 border-white/30 placeholder:text-gray-500" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
-
- or + + + {magicLinkSent ? ( +
+ Magic link sent — check your email for a sign-in link.
-
- - -
+ ) : ( +
+
+
+ +
+
+ + or + +
+
+ + +
+ )} + )} -
-

- {t('login.noAccount')}{' '} - - {t('login.signUp')} - -

-
+ {showSignUpLink && ( +
+

+ {t('login.noAccount')}{' '} + + {t('login.signUp')} + +

+
+ )}
diff --git a/apps/frontend/src/pages/register-page.tsx b/apps/frontend/src/pages/register-page.tsx index 49d8e06f4..175e90a2a 100644 --- a/apps/frontend/src/pages/register-page.tsx +++ b/apps/frontend/src/pages/register-page.tsx @@ -1,13 +1,21 @@ import { FormEvent, useState, useEffect } from 'react'; import { Helmet } from 'react-helmet-async'; import { useAuth } from '@/context/auth-context'; -import { useNavigate, Link, useLocation } from 'react-router-dom'; +import { + useNavigate, + Link, + useLocation, + useSearchParams, +} from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { PublicFooter } from '@/components/layout/public-footer'; +import { getAuthConfig } from '@/lib/auth-config'; +import { SsoButton, SsoDivider } from '@/components/auth/sso-button'; +import { useSsoErrorMessage } from '@/components/auth/use-sso-error-message'; const RegisterPage = () => { const [email, setEmail] = useState(''); @@ -24,6 +32,9 @@ const RegisterPage = () => { const { register, isLoggedIn } = useAuth(); const redirectTo = (location.state as { from?: { pathname?: string } })?.from ?.pathname; + const [searchParams] = useSearchParams(); + const ssoError = useSsoErrorMessage(searchParams.get('error')); + const { oidcAllowSignup, localLoginEnabled } = getAuthConfig(); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -92,138 +103,158 @@ const RegisterPage = () => {

{t('register.title')}

- {error && ( + {(error || ssoError) && (
- {error} + {error || ssoError}
)}
-
-
- -
- setEmail(e.target.value)} - placeholder={t('register.emailPlaceholder')} - className="bg-white/90 border-white/30 placeholder:text-gray-500" - /> -
+ {oidcAllowSignup && ( +
+ + {localLoginEnabled && }
+ )} -
- -
- setName(e.target.value)} - placeholder={t('register.displayNamePlaceholder')} - className="bg-white/90 border-white/30 placeholder:text-gray-500" - /> -
-
+ {!localLoginEnabled && !oidcAllowSignup && ( +

+ {t('sso.registrationClosed')} +

+ )} -
- -
- setPassword(e.target.value)} - className="bg-white/90 border-white/30 placeholder:text-gray-500" - /> + {localLoginEnabled && ( + +
+ +
+ setEmail(e.target.value)} + placeholder={t('register.emailPlaceholder')} + className="bg-white/90 border-white/30 placeholder:text-gray-500" + /> +
-
-
- -
- setConfirmPassword(e.target.value)} - className="bg-white/90 border-white/30 placeholder:text-gray-500" - /> +
+ +
+ setName(e.target.value)} + placeholder={t('register.displayNamePlaceholder')} + className="bg-white/90 border-white/30 placeholder:text-gray-500" + /> +
-
- {/* GDPR Consent Checkboxes */} -
-
- - setPrivacyPolicyConsent(checked as boolean) - } - className="mt-1" - /> -