@cosmicdrift/kumiko-bundled-features 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Session-State für den Browser-Renderer. Hält den aktuell eingeloggten
|
|
3
|
+
// User (Profile + aktive Tenant-Zuordnung + Memberships), reagiert auf
|
|
4
|
+
// Login/Logout/Switch-Tenant und refresh't die Daten automatisch.
|
|
5
|
+
//
|
|
6
|
+
// Warum Context + useReducer statt z.B. Zustand? Weil die State-Menge
|
|
7
|
+
// klein ist (ein Handful Felder, ein paar Transitionen) und wir damit
|
|
8
|
+
// eine Dependency weniger im Browser-Bundle haben. Die Consumer leben
|
|
9
|
+
// unter `<SessionProvider>`; der `useSession()`-Hook liefert den State
|
|
10
|
+
// und die Transitions.
|
|
11
|
+
|
|
12
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
|
13
|
+
import {
|
|
14
|
+
type CurrentUserProfile,
|
|
15
|
+
fetchCurrentUser,
|
|
16
|
+
fetchTenants,
|
|
17
|
+
type LoginFailure,
|
|
18
|
+
type LoginRequest,
|
|
19
|
+
login as loginApi,
|
|
20
|
+
logout as logoutApi,
|
|
21
|
+
switchTenant as switchTenantApi,
|
|
22
|
+
type TenantSummary,
|
|
23
|
+
} from "./auth-client";
|
|
24
|
+
|
|
25
|
+
export type SessionStatus = "loading" | "unauthenticated" | "authenticated";
|
|
26
|
+
|
|
27
|
+
export type SessionState = {
|
|
28
|
+
readonly status: SessionStatus;
|
|
29
|
+
readonly user: CurrentUserProfile | null;
|
|
30
|
+
readonly activeTenantId: string | null;
|
|
31
|
+
readonly tenants: readonly TenantSummary[];
|
|
32
|
+
/** Merged session-roles für den active tenant: globalRoles (z.B.
|
|
33
|
+
* SystemAdmin) + membership-roles des activeTenant. Server hat sie
|
|
34
|
+
* schon im JWT gemerged, aber das JWT ist HttpOnly + nicht JS-lesbar;
|
|
35
|
+
* Client computed dieselbe merge-Logik aus user.globalRoles +
|
|
36
|
+
* tenants[active].roles damit nav-filtering greift. Dedupliziert. */
|
|
37
|
+
readonly roles: readonly string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type SessionApi = SessionState & {
|
|
41
|
+
readonly login: (req: LoginRequest) => Promise<{ ok: true } | { ok: false; error: LoginFailure }>;
|
|
42
|
+
readonly logout: () => Promise<void>;
|
|
43
|
+
readonly switchTenant: (tenantId: string) => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const INITIAL: SessionState = {
|
|
47
|
+
status: "loading",
|
|
48
|
+
user: null,
|
|
49
|
+
activeTenantId: null,
|
|
50
|
+
tenants: [],
|
|
51
|
+
roles: [],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Exported damit tests den merge-pfad direkt pinnen können — der hier
|
|
55
|
+
// muss byte-identisch zum server-side merge in auth-routes.ts +
|
|
56
|
+
// login.write.ts sein, sonst sieht der Client andere session-rollen
|
|
57
|
+
// als der Server.
|
|
58
|
+
export function computeActiveRoles(
|
|
59
|
+
user: CurrentUserProfile | null,
|
|
60
|
+
activeTenantId: string | null,
|
|
61
|
+
tenants: readonly TenantSummary[],
|
|
62
|
+
): readonly string[] {
|
|
63
|
+
if (user === null) return [];
|
|
64
|
+
const membership =
|
|
65
|
+
activeTenantId !== null ? tenants.find((t) => t.tenantId === activeTenantId) : undefined;
|
|
66
|
+
const membershipRoles = membership?.roles ?? [];
|
|
67
|
+
// Set-Dedupe spiegelt server-side merge (auth-routes.ts switch-tenant +
|
|
68
|
+
// login.write.ts).
|
|
69
|
+
return Array.from(new Set([...user.globalRoles, ...membershipRoles]));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Internal — exposed for tests die einen Mock-SessionApi-Wert reinreichen
|
|
73
|
+
* wollen, ohne durch SessionProvider's refresh-Lifecycle zu müssen. App-
|
|
74
|
+
* Code nutzt SessionProvider + useSession; direkter Context-Zugriff ist
|
|
75
|
+
* für Tests/Stories. */
|
|
76
|
+
export const SessionContext = createContext<SessionApi | undefined>(undefined);
|
|
77
|
+
|
|
78
|
+
// Eine Refresh-Runde: /auth/tenants → wenn 401 nicht-eingeloggt, sonst
|
|
79
|
+
// parallel /user:me. Beides zusammen ergibt den vollen SessionState.
|
|
80
|
+
async function refresh(): Promise<SessionState> {
|
|
81
|
+
const tenants = await fetchTenants();
|
|
82
|
+
if (tenants === null) {
|
|
83
|
+
return {
|
|
84
|
+
status: "unauthenticated",
|
|
85
|
+
user: null,
|
|
86
|
+
activeTenantId: null,
|
|
87
|
+
tenants: [],
|
|
88
|
+
roles: [],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const user = await fetchCurrentUser();
|
|
92
|
+
if (user === null) {
|
|
93
|
+
return {
|
|
94
|
+
status: "unauthenticated",
|
|
95
|
+
user: null,
|
|
96
|
+
activeTenantId: null,
|
|
97
|
+
tenants: [],
|
|
98
|
+
roles: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
status: "authenticated",
|
|
103
|
+
user,
|
|
104
|
+
activeTenantId: tenants.activeTenantId,
|
|
105
|
+
tenants: tenants.tenants,
|
|
106
|
+
roles: computeActiveRoles(user, tenants.activeTenantId, tenants.tenants),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function SessionProvider({ children }: { readonly children: ReactNode }): ReactNode {
|
|
111
|
+
const [state, setState] = useState<SessionState>(INITIAL);
|
|
112
|
+
|
|
113
|
+
const doRefresh = useCallback(async () => {
|
|
114
|
+
const next = await refresh();
|
|
115
|
+
setState(next);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
void doRefresh();
|
|
120
|
+
}, [doRefresh]);
|
|
121
|
+
|
|
122
|
+
const login = useCallback<SessionApi["login"]>(
|
|
123
|
+
async (req) => {
|
|
124
|
+
const res = await loginApi(req);
|
|
125
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
126
|
+
await doRefresh();
|
|
127
|
+
return { ok: true };
|
|
128
|
+
},
|
|
129
|
+
[doRefresh],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const logout = useCallback<SessionApi["logout"]>(async () => {
|
|
133
|
+
await logoutApi();
|
|
134
|
+
setState({
|
|
135
|
+
status: "unauthenticated",
|
|
136
|
+
user: null,
|
|
137
|
+
activeTenantId: null,
|
|
138
|
+
tenants: [],
|
|
139
|
+
roles: [],
|
|
140
|
+
});
|
|
141
|
+
// Hard-Reload: React-Tree, dispatcher-live-Caches, EventSource —
|
|
142
|
+
// alles fliegt auf Null. Nach Logout ist das der billigste Weg zu
|
|
143
|
+
// sauberer Ausgangslage, ohne dass wir jeden einzelnen Consumer
|
|
144
|
+
// per Context-Bust invalidieren müssen.
|
|
145
|
+
if (typeof window !== "undefined") {
|
|
146
|
+
window.location.reload();
|
|
147
|
+
}
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const switchTenant = useCallback<SessionApi["switchTenant"]>(async (tenantId) => {
|
|
151
|
+
await switchTenantApi(tenantId);
|
|
152
|
+
// Tenant-Wechsel rotiert JWT + Cookies. React-Tree enthält
|
|
153
|
+
// tenant-gebundene Caches (Queries, Live-Events) — simpler
|
|
154
|
+
// Reload ist konsistent mit dem Logout-Pfad und vermeidet
|
|
155
|
+
// halbe State-Übergänge.
|
|
156
|
+
if (typeof window !== "undefined") {
|
|
157
|
+
window.location.reload();
|
|
158
|
+
}
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const api: SessionApi = { ...state, login, logout, switchTenant };
|
|
162
|
+
return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function useSession(): SessionApi {
|
|
166
|
+
const ctx = useContext(SessionContext);
|
|
167
|
+
if (ctx === undefined) {
|
|
168
|
+
throw new Error("useSession must be used inside <SessionProvider>");
|
|
169
|
+
}
|
|
170
|
+
return ctx;
|
|
171
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// SignupCompleteScreen — Magic-Link-Self-Signup, Step 2.
|
|
3
|
+
//
|
|
4
|
+
// Liest `?token=...` aus der URL, zeigt Form mit Password + Confirm.
|
|
5
|
+
// Submit triggert /api/auth/signup-confirm — bei Erfolg setzt der
|
|
6
|
+
// Server JWT + Cookies (Auto-Login!) und liefert tenantKey für den
|
|
7
|
+
// Post-Signup-Redirect.
|
|
8
|
+
//
|
|
9
|
+
// Token-Quelle ist read-once: parseUrlToken im useState-Initializer.
|
|
10
|
+
// Apps die einen anderen URL-Param nutzen, reichen `token` als Prop
|
|
11
|
+
// durch.
|
|
12
|
+
//
|
|
13
|
+
// Nach success: redirect via window.location.assign zu loggedInHref.
|
|
14
|
+
// Default-Pattern ist "/<tenantKey>/" — die App reicht ein Template
|
|
15
|
+
// rein. Default-Template "/" wäre auch valide (App hat dann eigene
|
|
16
|
+
// Routing-Logik die den eingeloggten User zur richtigen Page schickt).
|
|
17
|
+
|
|
18
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
20
|
+
import { confirmSignup } from "./auth-client";
|
|
21
|
+
import { AuthCard, authMutedLinkClass, parseUrlToken } from "./auth-form-primitives";
|
|
22
|
+
|
|
23
|
+
export type SignupCompleteScreenProps = {
|
|
24
|
+
readonly title?: string;
|
|
25
|
+
/** Override für den Token aus der URL — Apps die per server-side-
|
|
26
|
+
* Render einen Token reinreichen, brauchen das. Default: parsed aus
|
|
27
|
+
* `?token=...` in der URL. */
|
|
28
|
+
readonly token?: string;
|
|
29
|
+
/** Where to send the user after successful activation. Default "/" —
|
|
30
|
+
* Apps mit Multi-Tenant-Routing ersetzen das durch
|
|
31
|
+
* `(data) => "/" + data.tenantKey + "/"`. Function-form, weil nur
|
|
32
|
+
* nach success bekannt welcher tenantKey zugeteilt wurde. */
|
|
33
|
+
readonly loggedInHref?: string | ((args: { tenantKey: string }) => string);
|
|
34
|
+
/** href für "Schon einen Account?"-Link bei missing-token-Fall.
|
|
35
|
+
* Default "/login". */
|
|
36
|
+
readonly loginHref?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function SignupCompleteScreen({
|
|
40
|
+
title,
|
|
41
|
+
token: tokenProp,
|
|
42
|
+
loggedInHref = "/",
|
|
43
|
+
loginHref = "/login",
|
|
44
|
+
}: SignupCompleteScreenProps): ReactNode {
|
|
45
|
+
const t = useTranslation();
|
|
46
|
+
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
47
|
+
const [token] = useState(() => tokenProp ?? parseUrlToken());
|
|
48
|
+
const [password, setPassword] = useState("");
|
|
49
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
50
|
+
const [submitting, setSubmitting] = useState(false);
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
|
|
53
|
+
const doSubmit = async (): Promise<void> => {
|
|
54
|
+
setError(null);
|
|
55
|
+
if (password.length < 8) {
|
|
56
|
+
setError(t("auth.signupComplete.tooShort"));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (password !== confirmPassword) {
|
|
60
|
+
setError(t("auth.signupComplete.mismatch"));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setSubmitting(true);
|
|
64
|
+
const res = await confirmSignup(token, password);
|
|
65
|
+
setSubmitting(false);
|
|
66
|
+
if (res.ok) {
|
|
67
|
+
// Auto-Login: Cookies sind via Set-Cookie schon im Browser. Wir
|
|
68
|
+
// schicken den User direkt zur eingeloggten Page.
|
|
69
|
+
const target =
|
|
70
|
+
typeof loggedInHref === "function"
|
|
71
|
+
? loggedInHref({ tenantKey: res.data.tenantKey })
|
|
72
|
+
: loggedInHref;
|
|
73
|
+
window.location.assign(target);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (res.error.reason === "invalid_signup_token") {
|
|
77
|
+
setError(t("auth.errors.invalidSignupToken"));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (res.error.reason === "rate_limited") {
|
|
81
|
+
setError(t("auth.errors.rateLimited"));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setError(t("auth.errors.unknownError"));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
88
|
+
e?.preventDefault();
|
|
89
|
+
void doSubmit();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const effectiveTitle = title ?? t("auth.signupComplete.title");
|
|
93
|
+
|
|
94
|
+
// Kein Token in der URL → klare Message statt Form ohne Token
|
|
95
|
+
// (würde nur invalidSignupToken zeigen, verwirrend).
|
|
96
|
+
if (token === "") {
|
|
97
|
+
return (
|
|
98
|
+
<AuthCard title={effectiveTitle}>
|
|
99
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
100
|
+
<p className="text-sm text-muted-foreground">{t("auth.signupComplete.missingToken")}</p>
|
|
101
|
+
<a href={loginHref} className={authMutedLinkClass}>
|
|
102
|
+
{t("auth.signup.haveAccount")}
|
|
103
|
+
</a>
|
|
104
|
+
</div>
|
|
105
|
+
</AuthCard>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<AuthCard title={effectiveTitle}>
|
|
111
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
112
|
+
<p className="text-sm text-muted-foreground">{t("auth.signupComplete.intro")}</p>
|
|
113
|
+
<Form onSubmit={onSubmit}>
|
|
114
|
+
<Field id="signup-password" label={t("auth.signupComplete.password")} required>
|
|
115
|
+
<Input
|
|
116
|
+
kind="password"
|
|
117
|
+
id="signup-password"
|
|
118
|
+
name="signup-password"
|
|
119
|
+
value={password}
|
|
120
|
+
onChange={setPassword}
|
|
121
|
+
disabled={submitting}
|
|
122
|
+
required
|
|
123
|
+
autoComplete="new-password"
|
|
124
|
+
/>
|
|
125
|
+
</Field>
|
|
126
|
+
<Field
|
|
127
|
+
id="signup-confirm-password"
|
|
128
|
+
label={t("auth.signupComplete.confirmPassword")}
|
|
129
|
+
required
|
|
130
|
+
>
|
|
131
|
+
<Input
|
|
132
|
+
kind="password"
|
|
133
|
+
id="signup-confirm-password"
|
|
134
|
+
name="signup-confirm-password"
|
|
135
|
+
value={confirmPassword}
|
|
136
|
+
onChange={setConfirmPassword}
|
|
137
|
+
disabled={submitting}
|
|
138
|
+
required
|
|
139
|
+
autoComplete="new-password"
|
|
140
|
+
/>
|
|
141
|
+
</Field>
|
|
142
|
+
{error !== null && <Banner variant="error">{error}</Banner>}
|
|
143
|
+
<Button type="submit" loading={submitting} disabled={submitting}>
|
|
144
|
+
{submitting ? t("auth.signupComplete.submitting") : t("auth.signupComplete.submit")}
|
|
145
|
+
</Button>
|
|
146
|
+
</Form>
|
|
147
|
+
</div>
|
|
148
|
+
</AuthCard>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// SignupScreen — Magic-Link-Self-Signup, Step 1.
|
|
3
|
+
//
|
|
4
|
+
// Form mit Email-Input. Submit triggert /api/auth/signup-request
|
|
5
|
+
// (silent-success, kein account-enumeration). UI zeigt unconditional
|
|
6
|
+
// ein "Mail unterwegs"-Confirm mit Resend-Button — auch wenn die Email
|
|
7
|
+
// schon registriert ist (Server schickt dann dieselbe Mail mit dem
|
|
8
|
+
// existing Token aus Redis = idempotent).
|
|
9
|
+
//
|
|
10
|
+
// App ist verantwortlich, den Screen unter einer URL zu mounten (z.B.
|
|
11
|
+
// /signup) und ihn anonymous reachable zu machen (AuthPathGate VOR
|
|
12
|
+
// AuthGate). Apex-Marketing kann via Link "Kostenlos starten" auf den
|
|
13
|
+
// /signup-Pfad routen.
|
|
14
|
+
|
|
15
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
17
|
+
import { requestSignup } from "./auth-client";
|
|
18
|
+
import { AuthCard, authMutedLinkClass } from "./auth-form-primitives";
|
|
19
|
+
|
|
20
|
+
export type SignupScreenProps = {
|
|
21
|
+
readonly title?: string;
|
|
22
|
+
readonly subtitle?: ReactNode;
|
|
23
|
+
/** href für den "Bereits einen Account?"-Link. App-spezifisch — Default
|
|
24
|
+
* "/login". */
|
|
25
|
+
readonly loginHref?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function SignupScreen({
|
|
29
|
+
title,
|
|
30
|
+
subtitle,
|
|
31
|
+
loginHref = "/login",
|
|
32
|
+
}: SignupScreenProps): ReactNode {
|
|
33
|
+
const t = useTranslation();
|
|
34
|
+
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
35
|
+
const [email, setEmail] = useState("");
|
|
36
|
+
const [submitting, setSubmitting] = useState(false);
|
|
37
|
+
const [done, setDone] = useState(false);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
const doSubmit = async (): Promise<void> => {
|
|
41
|
+
setSubmitting(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
try {
|
|
44
|
+
const res = await requestSignup(email);
|
|
45
|
+
if (res.ok) {
|
|
46
|
+
setDone(true);
|
|
47
|
+
} else if (res.error.reason === "rate_limited") {
|
|
48
|
+
const minutes =
|
|
49
|
+
res.error.retryAfterSeconds !== undefined
|
|
50
|
+
? Math.ceil(res.error.retryAfterSeconds / 60)
|
|
51
|
+
: undefined;
|
|
52
|
+
setError(
|
|
53
|
+
minutes !== undefined
|
|
54
|
+
? t("auth.errors.accountLockedRetry", { minutes })
|
|
55
|
+
: t("auth.errors.rateLimited"),
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
setError(t("auth.errors.unknownError"));
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
setError(t("auth.errors.unknownError"));
|
|
62
|
+
} finally {
|
|
63
|
+
setSubmitting(false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
68
|
+
e?.preventDefault();
|
|
69
|
+
void doSubmit();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const onResend = async (): Promise<void> => {
|
|
73
|
+
// Resend nutzt den gleichen Endpunkt — Server returnt den existing
|
|
74
|
+
// Token aus Redis und schickt eine zweite Mail mit dem GLEICHEN
|
|
75
|
+
// Activation-Link (alte Mail bleibt also gültig).
|
|
76
|
+
setSubmitting(true);
|
|
77
|
+
setError(null);
|
|
78
|
+
try {
|
|
79
|
+
await requestSignup(email);
|
|
80
|
+
} finally {
|
|
81
|
+
setSubmitting(false);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const effectiveTitle = title ?? t("auth.signup.title");
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<AuthCard title={effectiveTitle} subtitle={subtitle}>
|
|
89
|
+
{done ? (
|
|
90
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
91
|
+
<Banner variant="info">
|
|
92
|
+
<p className="font-medium text-foreground">{t("auth.signup.successTitle")}</p>
|
|
93
|
+
<p className="mt-1">{t("auth.signup.successBody")}</p>
|
|
94
|
+
</Banner>
|
|
95
|
+
<Button variant="secondary" onClick={onResend} disabled={submitting}>
|
|
96
|
+
{t("auth.signup.resend")}
|
|
97
|
+
</Button>
|
|
98
|
+
<a href={loginHref} className={authMutedLinkClass}>
|
|
99
|
+
{t("auth.signup.haveAccount")}
|
|
100
|
+
</a>
|
|
101
|
+
</div>
|
|
102
|
+
) : (
|
|
103
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
104
|
+
<p className="text-sm text-muted-foreground">{t("auth.signup.intro")}</p>
|
|
105
|
+
<Form onSubmit={onSubmit}>
|
|
106
|
+
<Field id="signup-email" label={t("auth.signup.email")} required>
|
|
107
|
+
<Input
|
|
108
|
+
kind="email"
|
|
109
|
+
id="signup-email"
|
|
110
|
+
name="signup-email"
|
|
111
|
+
value={email}
|
|
112
|
+
onChange={setEmail}
|
|
113
|
+
disabled={submitting}
|
|
114
|
+
required
|
|
115
|
+
autoComplete="email"
|
|
116
|
+
/>
|
|
117
|
+
</Field>
|
|
118
|
+
{error !== null && <Banner variant="error">{error}</Banner>}
|
|
119
|
+
<Button type="submit" loading={submitting} disabled={submitting}>
|
|
120
|
+
{submitting ? t("auth.signup.submitting") : t("auth.signup.submit")}
|
|
121
|
+
</Button>
|
|
122
|
+
</Form>
|
|
123
|
+
<a href={loginHref} className={`${authMutedLinkClass} self-center`}>
|
|
124
|
+
{t("auth.signup.haveAccount")}
|
|
125
|
+
</a>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</AuthCard>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// TenantSwitcher — kleines Dropdown, zeigt den aktiven Tenant und
|
|
3
|
+
// erlaubt Wechsel zu anderen Memberships. Auf Radix-DropdownMenu für
|
|
4
|
+
// konsistentes Verhalten (Click-outside/Escape/Keyboard-Nav).
|
|
5
|
+
//
|
|
6
|
+
// Rendert NICHT wenn kein User eingeloggt ist und auch NICHT wenn der
|
|
7
|
+
// User nur einen Tenant hat (Single-Tenant-Apps brauchen keinen
|
|
8
|
+
// Switcher).
|
|
9
|
+
//
|
|
10
|
+
// Der Display-Name kommt aus einem optionalen `tenantName`-Prop
|
|
11
|
+
// oder fallback zum ID-Hash. Design-Entscheidung: der TenantSwitcher
|
|
12
|
+
// holt NICHT selbst tenant:query:me — das würde Round-Trips pro Mount
|
|
13
|
+
// kosten und bräuchte Query-Caching-Infrastruktur. Stattdessen reicht
|
|
14
|
+
// der Host den Resolver rein (kommt z.B. aus einer useTenantNames()-
|
|
15
|
+
// Hook die einmalig am App-Boot geladen wird).
|
|
16
|
+
|
|
17
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
18
|
+
import {
|
|
19
|
+
cn,
|
|
20
|
+
DropdownMenu,
|
|
21
|
+
DropdownMenuCheckboxItem,
|
|
22
|
+
DropdownMenuContent,
|
|
23
|
+
DropdownMenuLabel,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from "@cosmicdrift/kumiko-renderer-web";
|
|
26
|
+
import { Building2, ChevronDown } from "lucide-react";
|
|
27
|
+
import { type ReactNode, useCallback, useState } from "react";
|
|
28
|
+
import { useSession } from "./session";
|
|
29
|
+
|
|
30
|
+
export type TenantSwitcherProps = {
|
|
31
|
+
/** Optional: liefert einen menschenlesbaren Namen pro Tenant-ID.
|
|
32
|
+
* Default: die ID wird direkt angezeigt (kurze UUID genügt für
|
|
33
|
+
* Dev-Umgebungen). Für prod sollte der Host z.B. die Tenant-Namen
|
|
34
|
+
* beim Login cachen und hier durchreichen. */
|
|
35
|
+
readonly tenantName?: (tenantId: string) => string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
|
|
39
|
+
const t = useTranslation();
|
|
40
|
+
const { user, tenants, activeTenantId, switchTenant } = useSession();
|
|
41
|
+
const [switching, setSwitching] = useState<string | null>(null);
|
|
42
|
+
|
|
43
|
+
const handleSwitch = useCallback(
|
|
44
|
+
async (tenantId: string) => {
|
|
45
|
+
if (tenantId === activeTenantId) return;
|
|
46
|
+
setSwitching(tenantId);
|
|
47
|
+
try {
|
|
48
|
+
await switchTenant(tenantId);
|
|
49
|
+
} finally {
|
|
50
|
+
// In der Praxis führt switchTenant zu einem full-page reload,
|
|
51
|
+
// also sehen wir den cleared-state nie — das `finally` ist nur
|
|
52
|
+
// für den Edge-Case dass switchTenant throwt, damit die UI
|
|
53
|
+
// nicht mit dem Spinner hängen bleibt.
|
|
54
|
+
setSwitching(null);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
[activeTenantId, switchTenant],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const nameOf = (tenantId: string): string =>
|
|
61
|
+
tenantName !== undefined ? tenantName(tenantId) : tenantId.slice(0, 8);
|
|
62
|
+
|
|
63
|
+
// Rendering-Gate: kein User → nix; nur ein Tenant → auch nix
|
|
64
|
+
// (Single-Tenant-Apps brauchen keinen Switcher).
|
|
65
|
+
if (user === null || tenants.length <= 1) return null;
|
|
66
|
+
|
|
67
|
+
const activeLabel =
|
|
68
|
+
activeTenantId !== null ? nameOf(activeTenantId) : t("auth.tenant.switcher.none");
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<DropdownMenu>
|
|
72
|
+
<DropdownMenuTrigger asChild>
|
|
73
|
+
{/* kumiko-lint-ignore primitives-discipline radix-asChild braucht DOM-Element als Trigger; Native kriegt eigene .native.tsx-Variante mit ActionSheet */}
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
className={cn(
|
|
77
|
+
"inline-flex items-center gap-2 rounded-md border border-input bg-background px-2 py-1 text-sm",
|
|
78
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
79
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
83
|
+
<span className="max-w-[14ch] truncate">{activeLabel}</span>
|
|
84
|
+
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
85
|
+
</button>
|
|
86
|
+
</DropdownMenuTrigger>
|
|
87
|
+
<DropdownMenuContent align="end" className="min-w-[14rem]">
|
|
88
|
+
<DropdownMenuLabel>{t("auth.tenant.switcher.label")}</DropdownMenuLabel>
|
|
89
|
+
{tenants.map((membership) => {
|
|
90
|
+
const isActive = membership.tenantId === activeTenantId;
|
|
91
|
+
const isSwitching = switching === membership.tenantId;
|
|
92
|
+
return (
|
|
93
|
+
<DropdownMenuCheckboxItem
|
|
94
|
+
key={membership.tenantId}
|
|
95
|
+
checked={isActive}
|
|
96
|
+
disabled={isSwitching}
|
|
97
|
+
onSelect={(e) => {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
void handleSwitch(membership.tenantId);
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<div className="flex flex-col items-start min-w-0">
|
|
103
|
+
<span className="truncate font-medium">{nameOf(membership.tenantId)}</span>
|
|
104
|
+
{membership.roles.length > 0 && (
|
|
105
|
+
<span className="text-xs text-muted-foreground truncate">
|
|
106
|
+
{membership.roles.join(", ")}
|
|
107
|
+
</span>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</DropdownMenuCheckboxItem>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</DropdownMenuContent>
|
|
114
|
+
</DropdownMenu>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Liest den eingeloggten User + die Rollen aus dem aktiven Tenant aus
|
|
3
|
+
// der Session. Der Hook ist die Standard-Brücke zwischen Session-State
|
|
4
|
+
// und Layout-Komponenten (WorkspaceShell.user, DefaultAppShell-Gates,
|
|
5
|
+
// custom permission checks).
|
|
6
|
+
//
|
|
7
|
+
// Returns undefined solange:
|
|
8
|
+
// - die Session lädt (status="loading")
|
|
9
|
+
// - der User unauthenticated ist
|
|
10
|
+
// - der activeTenantId in der tenants-Liste fehlt (defensiver Pfad —
|
|
11
|
+
// sollte nicht passieren, schadet aber nicht zu prüfen)
|
|
12
|
+
//
|
|
13
|
+
// WorkspaceShell + DefaultAppShell akzeptieren undefined als "keine
|
|
14
|
+
// Workspaces sichtbar", was im Loading-/Logged-Out-Fall die richtige UX
|
|
15
|
+
// ist. Apps die mehr brauchen (Loading-Indicator etc.) prüfen
|
|
16
|
+
// useSession().status direkt.
|
|
17
|
+
|
|
18
|
+
import { useMemo } from "react";
|
|
19
|
+
import { useSession } from "./session";
|
|
20
|
+
|
|
21
|
+
export type ShellUser = {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly roles: readonly string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function useShellUser(): ShellUser | undefined {
|
|
27
|
+
const session = useSession();
|
|
28
|
+
return useMemo(() => {
|
|
29
|
+
if (session.status !== "authenticated" || session.user === null) return undefined;
|
|
30
|
+
const activeTenant = session.tenants.find((t) => t.tenantId === session.activeTenantId);
|
|
31
|
+
if (activeTenant === undefined) return undefined;
|
|
32
|
+
return { id: session.user.id, roles: activeTenant.roles };
|
|
33
|
+
}, [session.status, session.user, session.activeTenantId, session.tenants]);
|
|
34
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// UserMenu — Avatar-Dropdown in der Topbar/Sidebar. Zeigt Name/Email
|
|
3
|
+
// des aktuellen Users + Logout-Button. Auf Radix-DropdownMenu, damit
|
|
4
|
+
// Click-outside, Escape, Focus-Management, Keyboard-Nav (↑↓/Home/End)
|
|
5
|
+
// und ARIA-Roles aus der Kiste funktionieren.
|
|
6
|
+
//
|
|
7
|
+
// Rendert NICHTS wenn kein User eingeloggt ist — Hosts dürfen das
|
|
8
|
+
// Component außerhalb des AuthGate einhängen ohne dass ein harter
|
|
9
|
+
// Fehler entsteht.
|
|
10
|
+
|
|
11
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
12
|
+
import {
|
|
13
|
+
cn,
|
|
14
|
+
DropdownMenu,
|
|
15
|
+
DropdownMenuContent,
|
|
16
|
+
DropdownMenuItem,
|
|
17
|
+
DropdownMenuLabel,
|
|
18
|
+
DropdownMenuSeparator,
|
|
19
|
+
DropdownMenuTrigger,
|
|
20
|
+
} from "@cosmicdrift/kumiko-renderer-web";
|
|
21
|
+
import { ChevronDown, LogOut } from "lucide-react";
|
|
22
|
+
import type { ReactNode } from "react";
|
|
23
|
+
import { useSession } from "./session";
|
|
24
|
+
|
|
25
|
+
export type UserMenuProps = {
|
|
26
|
+
/** Zusätzliche Menu-Items über dem Logout. Per-item class/behaviour
|
|
27
|
+
* controlliert der Caller — wir packen nur den Frame drumrum. */
|
|
28
|
+
readonly children?: ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function initials(value: string): string {
|
|
32
|
+
// Vor- und Nachname falls Displayname einen Spacebar hat, sonst
|
|
33
|
+
// erste zwei Chars der Email-Lokalseite. Deterministisch,
|
|
34
|
+
// damit der Avatar-Content nicht bei jedem Re-Render flattert.
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
if (trimmed.length === 0) return "?";
|
|
37
|
+
const parts = trimmed.split(/\s+/);
|
|
38
|
+
if (parts.length >= 2) {
|
|
39
|
+
return (parts[0]?.[0] ?? "").concat(parts[1]?.[0] ?? "").toUpperCase();
|
|
40
|
+
}
|
|
41
|
+
return trimmed.slice(0, 2).toUpperCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function UserMenu({ children }: UserMenuProps): ReactNode {
|
|
45
|
+
const t = useTranslation();
|
|
46
|
+
const { user, logout } = useSession();
|
|
47
|
+
|
|
48
|
+
if (user === null) return null;
|
|
49
|
+
|
|
50
|
+
const displayName = user.displayName.length > 0 ? user.displayName : user.email;
|
|
51
|
+
const avatarText = initials(displayName);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<DropdownMenu>
|
|
55
|
+
<DropdownMenuTrigger asChild>
|
|
56
|
+
{/* kumiko-lint-ignore primitives-discipline radix-asChild braucht DOM-Element als Trigger; Native kriegt eigene .native.tsx-Variante mit ActionSheet */}
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className={cn(
|
|
60
|
+
"inline-flex items-center gap-2 rounded-md px-2 py-1 text-sm",
|
|
61
|
+
"text-foreground hover:bg-accent hover:text-accent-foreground",
|
|
62
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<span
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground"
|
|
68
|
+
>
|
|
69
|
+
{avatarText}
|
|
70
|
+
</span>
|
|
71
|
+
<span className="hidden sm:inline max-w-[12ch] truncate">{displayName}</span>
|
|
72
|
+
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
73
|
+
</button>
|
|
74
|
+
</DropdownMenuTrigger>
|
|
75
|
+
<DropdownMenuContent align="end" aria-label={t("auth.user.menu.label")}>
|
|
76
|
+
<DropdownMenuLabel className="text-xs">
|
|
77
|
+
<div className="font-medium text-foreground truncate">{displayName}</div>
|
|
78
|
+
<div className="truncate">{user.email}</div>
|
|
79
|
+
</DropdownMenuLabel>
|
|
80
|
+
<DropdownMenuSeparator />
|
|
81
|
+
{children}
|
|
82
|
+
<DropdownMenuItem onSelect={() => void logout()}>
|
|
83
|
+
<LogOut className="h-4 w-4" />
|
|
84
|
+
<span>{t("auth.user.menu.logout")}</span>
|
|
85
|
+
</DropdownMenuItem>
|
|
86
|
+
</DropdownMenuContent>
|
|
87
|
+
</DropdownMenu>
|
|
88
|
+
);
|
|
89
|
+
}
|