From acc35c055c113cf505dac45c4ab62d7b11e3c2ee Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Wed, 9 Sep 2026 08:13:01 -0400 Subject: [PATCH] Phase 3: team-mode owner setup, user management, shared workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes AUTH_MODE=team usable end to end. - resolveTeamContext (middleware/ensure-user/team.ts): a session resolves to a membership in the single shared workspace. No per-user fallback org — a signed-in user with no membership is treated as signed out, so the owner can actually remove people. - teamProvisioning.ts: one path that writes user + credential account + member together (hashPassword from better-auth/crypto). Shared by both entry points. - /api/team-setup (raw route, outside auth middleware): GET reports whether an owner is needed; POST creates the first owner + the shared org, then self-disables once any user exists. - /setup route + sign-in redirect: first run sends you to create the owner. - teamUsers server functions (owner/admin-gated): list / create (with temp password) / reset password / remove. Removal drops membership + sessions, keeps the user row for historical attribution. - Settings gains a "Users" tab in team mode (TeamUsers.tsx). - docs/SELF_HOSTING_TEAM_MODE.md: activation runbook (.env, build, first owner). No DB migration — all rows are existing better-auth tables. tsc / oxlint / knip clean. New teamProvisioning.test.ts (4 cases) passes; suite otherwise unchanged (pre-existing samSkills.test.ts CRLF failure only). Co-Authored-By: Claude Sonnet 5 --- docs/SELF_HOSTING_TEAM_MODE.md | 79 ++++ src/client/features/auth/teamSetup.ts | 45 +++ src/client/features/team/TeamUsers.tsx | 345 ++++++++++++++++++ src/lib/auth-mode.ts | 4 + src/middleware/ensure-user/resolve.ts | 11 +- src/middleware/ensure-user/team.ts | 51 +++ src/routeTree.gen.ts | 63 ++++ src/routes/_app/settings.tsx | 6 +- src/routes/_app/settings/users.tsx | 13 + src/routes/_auth.setup.tsx | 105 ++++++ src/routes/_auth.sign-in.tsx | 17 +- src/routes/api/team-setup.ts | 102 ++++++ .../auth/repositories/AuthRepository.ts | 27 ++ .../features/team/teamProvisioning.test.ts | 145 ++++++++ src/server/features/team/teamProvisioning.ts | 130 +++++++ src/serverFunctions/teamUsers.ts | 151 ++++++++ 16 files changed, 1287 insertions(+), 7 deletions(-) create mode 100644 docs/SELF_HOSTING_TEAM_MODE.md create mode 100644 src/client/features/auth/teamSetup.ts create mode 100644 src/client/features/team/TeamUsers.tsx create mode 100644 src/middleware/ensure-user/team.ts create mode 100644 src/routes/_app/settings/users.tsx create mode 100644 src/routes/_auth.setup.tsx create mode 100644 src/routes/api/team-setup.ts create mode 100644 src/server/features/team/teamProvisioning.test.ts create mode 100644 src/server/features/team/teamProvisioning.ts create mode 100644 src/serverFunctions/teamUsers.ts diff --git a/docs/SELF_HOSTING_TEAM_MODE.md b/docs/SELF_HOSTING_TEAM_MODE.md new file mode 100644 index 0000000..ca874cd --- /dev/null +++ b/docs/SELF_HOSTING_TEAM_MODE.md @@ -0,0 +1,79 @@ +# Self-hosting with team logins (`AUTH_MODE=team`) + +`team` mode turns the app into a single shared workspace with individual +email/password logins. There is no billing, no Google login, no email +verification, and no self-serve signup — the owner provisions every account. + +Use it when you want your team on one internal instance and need to see who did +what. For a public multi-tenant product, use `hosted` instead. + +## What you get + +- Email/password sign-in for each teammate. +- One shared workspace (organization). Everyone works in the same projects. +- Roles: **owner** (you), **admin** (full access except billing/owner actions), + **member** (research + view). +- Two ways to add people: + - **Settings → Users** — create an account with a temporary password directly. + - **Settings → Organization** — send an email invite link (needs no email + provider config to create the invite; the link is shown in the UI). + +## 1. Set environment variables + +Edit `.env` in the deployment directory (for the pm2 setup that is +`/home/dev/DOCKER/OPEN-SEO/open-seo/.env`): + +```sh +AUTH_MODE=team +BETTER_AUTH_URL=https://seo.thedomainnest.com # the exact public origin, https +BETTER_AUTH_SECRET=<64 hex chars> # openssl rand -hex 32 +``` + +`AUTH_MODE` is compiled into the browser bundle, so it must be present when +`vite build` runs — not only at runtime. Vite reads this same `.env` at build +time, so one edit covers both as long as the build runs in this directory. + +Keep the existing `DATABASE_URL`, `DATAFORSEO_API_KEY`, etc. + +## 2. Rebuild and restart + +If your pipeline builds on push, commit/push and let it run. To do it by hand: + +```sh +cd /home/dev/DOCKER/OPEN-SEO/open-seo +pnpm install --frozen-lockfile +pnpm build +pm2 restart OPEN-SEO --update-env +``` + +Confirm config: `curl -s http://127.0.0.1:3001/api/health | jq .checks.auth` +should report `team`. + +## 3. Create the owner account + +Open the site. With `team` mode on and no users yet, `/sign-in` redirects to +`/setup`. Fill in name, email, password → **Create workspace**. You are signed +in as the owner. + +`/setup` disables itself the moment the owner exists. If you ever need to reset, +delete all rows from `user` (and `member`, `account`, `session`) in Postgres and +reload. + +## 4. Add your team + +**Settings → Users → Add user.** Enter their email, name, a temporary password, +and a role. Share the password out-of-band; they change it after signing in +(Settings → Personal, once that lands) or you reset it from the same screen. + +Removing a user drops their membership and signs them out everywhere. Their +`user` row is kept so past activity still attributes correctly; re-adding them +issues a fresh password. + +## Notes + +- Password reset by email is not available in `team` mode. The owner/admins + reset passwords from Settings → Users. +- The MCP server and its OAuth flow are hosted-only for now; `team` deployments + serve the app UI only. +- Rolling back: set `AUTH_MODE=local_noauth`, rebuild, restart. Existing users + and data stay in the database, just unused. diff --git a/src/client/features/auth/teamSetup.ts b/src/client/features/auth/teamSetup.ts new file mode 100644 index 0000000..9b37332 --- /dev/null +++ b/src/client/features/auth/teamSetup.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +const statusSchema = z.object({ + mode: z.enum(["team", "other"]), + needsOwner: z.boolean(), +}); + +type TeamSetupStatus = z.infer; + +const OTHER: TeamSetupStatus = { mode: "other", needsOwner: false }; + +const bootstrapResultSchema = z.object({ + ok: z.boolean().optional(), + error: z.string().optional(), +}); + +// Talks to the raw /api/team-setup route (which lives outside the auth +// middleware, so it works before any session exists). +export async function fetchTeamSetupStatus(): Promise { + try { + const response = await fetch("/api/team-setup", { method: "GET" }); + if (!response.ok) return OTHER; + const raw: unknown = await response.json(); + return statusSchema.catch(OTHER).parse(raw); + } catch { + return OTHER; + } +} + +export async function bootstrapTeamOwner(input: { + email: string; + name: string; + password: string; +}): Promise { + const response = await fetch("/api/team-setup", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + const raw: unknown = await response.json().catch(() => ({})); + const data = bootstrapResultSchema.catch({}).parse(raw); + if (!response.ok || !data.ok) { + throw new Error(data.error || "We couldn't complete setup. Try again."); + } +} diff --git a/src/client/features/team/TeamUsers.tsx b/src/client/features/team/TeamUsers.tsx new file mode 100644 index 0000000..4e13a05 --- /dev/null +++ b/src/client/features/team/TeamUsers.tsx @@ -0,0 +1,345 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + createTeamUser, + listTeamUsers, + removeTeamUser, + resetTeamUserPassword, +} from "@/serverFunctions/teamUsers"; + +type TeamUser = Awaited>[number]; + +const TEAM_USERS_KEY = ["team-users"] as const; + +function errorText(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +// `team` mode account management: the owner/admins create sign-in credentials +// directly here. Invitations (email links) live on the Organization tab. +export function TeamUsers() { + const queryClient = useQueryClient(); + const [isAddOpen, setIsAddOpen] = useState(false); + const [resetFor, setResetFor] = useState(null); + + const usersQuery = useQuery({ + queryKey: TEAM_USERS_KEY, + queryFn: () => listTeamUsers(), + }); + + const refresh = () => + queryClient.invalidateQueries({ queryKey: TEAM_USERS_KEY }); + + const removeMutation = useMutation({ + mutationFn: (userId: string) => removeTeamUser({ data: { userId } }), + onSuccess: () => { + toast.success("User removed"); + void refresh(); + }, + onError: (error) => + toast.error(errorText(error, "We couldn't remove that user.")), + }); + + if (usersQuery.isError) { + return ( +
+

+ We couldn’t load the user list. +

+ +
+ ); + } + + const users = usersQuery.data ?? []; + + return ( +
+
+

Users

+ +
+

+ Create a sign-in for each teammate. Share the email and temporary + password; they can change it after signing in. +

+ + {usersQuery.isPending ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
UserRoleAdded +
+
{user.name}
+
+ {user.email} +
+
{user.role} + {new Date(user.joinedAt).toLocaleDateString()} + + {user.isOwner ? null : ( +
+ + {user.isSelf ? null : ( + + )} +
+ )} +
+
+ )} + + {isAddOpen ? ( + setIsAddOpen(false)} + onCreated={() => { + setIsAddOpen(false); + void refresh(); + }} + /> + ) : null} + + {resetFor ? ( + setResetFor(null)} + onDone={() => setResetFor(null)} + /> + ) : null} +
+ ); +} + +function AddUserModal({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: () => void; +}) { + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState<"admin" | "member">("member"); + + const mutation = useMutation({ + mutationFn: () => createTeamUser({ data: { email, name, password, role } }), + onSuccess: () => { + toast.success("User created"); + onCreated(); + }, + onError: (error) => + toast.error(errorText(error, "We couldn't create that user.")), + }); + + return ( + +
{ + event.preventDefault(); + mutation.mutate(); + }} + > + setName(event.target.value)} + required + /> + setEmail(event.target.value)} + required + /> + setPassword(event.target.value)} + minLength={8} + required + /> + +
+ + +
+
+
+ ); +} + +function ResetPasswordModal({ + user, + onClose, + onDone, +}: { + user: TeamUser; + onClose: () => void; + onDone: () => void; +}) { + const [password, setPassword] = useState(""); + + const mutation = useMutation({ + mutationFn: () => + resetTeamUserPassword({ data: { userId: user.userId, password } }), + onSuccess: () => { + toast.success(`Password reset for ${user.email}`); + onDone(); + }, + onError: (error) => + toast.error(errorText(error, "We couldn't reset that password.")), + }); + + return ( + +
{ + event.preventDefault(); + mutation.mutate(); + }} + > +

+ Sets a new password and signs the user out of all devices. +

+ setPassword(event.target.value)} + minLength={8} + required + /> +
+ + +
+
+
+ ); +} + +function ModalShell({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: React.ReactNode; +}) { + return ( +
+
+
+

{title}

+ +
+ {children} +
+
+ ); +} diff --git a/src/lib/auth-mode.ts b/src/lib/auth-mode.ts index 571ad3b..fae8350 100644 --- a/src/lib/auth-mode.ts +++ b/src/lib/auth-mode.ts @@ -47,6 +47,10 @@ export function isTeamAuthMode(value: string | null | undefined) { return getAuthMode(value) === "team"; } +export function isTeamClientAuthMode() { + return isTeamAuthMode(import.meta.env.AUTH_MODE); +} + // "Is there a Better Auth login session?" — true for both the paid hosted SaaS // and the internal `team` mode. Use this (not isHostedAuthMode) wherever the // question is "does this request carry a real user session?" rather than "is diff --git a/src/middleware/ensure-user/resolve.ts b/src/middleware/ensure-user/resolve.ts index 99974d8..d5718f1 100644 --- a/src/middleware/ensure-user/resolve.ts +++ b/src/middleware/ensure-user/resolve.ts @@ -1,8 +1,9 @@ import { env } from "cloudflare:workers"; -import { getAuthMode, isSessionAuthMode } from "@/lib/auth-mode"; +import { getAuthMode } from "@/lib/auth-mode"; import { resolveCloudflareAccessContext } from "./cloudflareAccess"; import { resolveLocalNoAuthContext } from "./delegated"; import { resolveHostedContext } from "./hosted"; +import { resolveTeamContext } from "./team"; import type { EnsuredUserContext } from "./types"; // Resolves the authenticated user for a request's headers across every auth @@ -15,10 +16,10 @@ export async function resolveUserContextFromHeaders( if (authMode === "local_noauth") { return resolveLocalNoAuthContext(); } - if (isSessionAuthMode(authMode)) { - // `hosted` and `team` both resolve a Better Auth session; they differ only - // in config requirements (checked in resolveHostedContext) and which - // SaaS-only features are wired around them. + if (authMode === "team") { + return resolveTeamContext(headers); + } + if (authMode === "hosted") { return resolveHostedContext(headers); } return resolveCloudflareAccessContext(headers); diff --git a/src/middleware/ensure-user/team.ts b/src/middleware/ensure-user/team.ts new file mode 100644 index 0000000..6cf572d --- /dev/null +++ b/src/middleware/ensure-user/team.ts @@ -0,0 +1,51 @@ +import { getAuth, hasSessionAuthConfig } from "@/lib/auth"; +import { AuthRepository } from "@/server/auth/repositories/AuthRepository"; +import { AppError } from "@/server/lib/errors"; +import type { EnsuredUserContext } from "./types"; + +// `team` mode: a Better Auth session resolves to a membership in the single +// shared workspace. Unlike hosted, there is no per-user fallback organization — +// a signed-in user with no membership was removed by the owner, so they are +// treated as signed out rather than handed a fresh personal workspace. +export async function resolveTeamContext( + headers: Headers, +): Promise { + if (!hasSessionAuthConfig()) { + throw new AppError( + "AUTH_CONFIG_MISSING", + "team mode needs BETTER_AUTH_URL and BETTER_AUTH_SECRET (32+ characters) on the deployment.", + ); + } + + const session = await getAuth().api.getSession({ headers }); + + if (!session?.user?.id || !session.user.email) { + throw new AppError("UNAUTHENTICATED"); + } + + const organizationId = await AuthRepository.findFirstOrganizationIdForUser( + session.user.id, + ); + + if (!organizationId) { + throw new AppError("UNAUTHENTICATED"); + } + + const membership = await AuthRepository.getMembership( + session.user.id, + organizationId, + ); + + if (!membership) { + throw new AppError("UNAUTHENTICATED"); + } + + return { + userId: session.user.id, + userEmail: session.user.email, + // `team` mode has no email-verification step. + emailVerified: true, + organizationId, + role: membership.role, + }; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 6e8d964..025b777 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,12 +18,14 @@ import { Route as AuthRouteImport } from './routes/_auth' import { Route as ProjectRouteRouteImport } from './routes/_project/route' import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppIndexRouteImport } from './routes/_app/index' +import { Route as ApiTeamSetupRouteImport } from './routes/api/team-setup' import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as AcceptInvitationIdRouteImport } from './routes/accept-invitation.$id' import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe' import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' +import { Route as AuthSetupRouteImport } from './routes/_auth.setup' import { Route as AppTeamRouteImport } from './routes/_app/team' import { Route as AppSupportRouteImport } from './routes/_app/support' import { Route as AppSettingsRouteImport } from './routes/_app/settings' @@ -36,6 +38,7 @@ import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/ind import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat' +import { Route as AppSettingsUsersRouteImport } from './routes/_app/settings/users' import { Route as AppSettingsOrganizationRouteImport } from './routes/_app/settings/organization' import { Route as AppHelpOpenrouterApiKeyRouteImport } from './routes/_app/help/openrouter-api-key' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' @@ -103,6 +106,11 @@ const AppIndexRoute = AppIndexRouteImport.update({ path: '/', getParentRoute: () => AppRouteRoute, } as any) +const ApiTeamSetupRoute = ApiTeamSetupRouteImport.update({ + id: '/api/team-setup', + path: '/api/team-setup', + getParentRoute: () => rootRouteImport, +} as any) const ApiHealthRoute = ApiHealthRouteImport.update({ id: '/api/health', path: '/api/health', @@ -134,6 +142,11 @@ const AuthSignInRoute = AuthSignInRouteImport.update({ path: '/sign-in', getParentRoute: () => AuthRoute, } as any) +const AuthSetupRoute = AuthSetupRouteImport.update({ + id: '/setup', + path: '/setup', + getParentRoute: () => AuthRoute, +} as any) const AppTeamRoute = AppTeamRouteImport.update({ id: '/team', path: '/team', @@ -197,6 +210,11 @@ const AuthenticatedOnboardingChatRoute = path: '/onboarding/chat', getParentRoute: () => AuthenticatedRoute, } as any) +const AppSettingsUsersRoute = AppSettingsUsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => AppSettingsRoute, +} as any) const AppSettingsOrganizationRoute = AppSettingsOrganizationRouteImport.update({ id: '/organization', path: '/organization', @@ -350,16 +368,19 @@ export interface FileRoutesByFullPath { '/settings': typeof AppSettingsRouteWithChildren '/support': typeof AppSupportRoute '/team': typeof AppTeamRoute + '/setup': typeof AuthSetupRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute '/subscribe': typeof AuthenticatedSubscribeRoute '/accept-invitation/$id': typeof AcceptInvitationIdRoute '/api/health': typeof ApiHealthRoute + '/api/team-setup': typeof ApiTeamSetupRoute '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute '/settings/organization': typeof AppSettingsOrganizationRoute + '/settings/users': typeof AppSettingsUsersRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute @@ -399,15 +420,18 @@ export interface FileRoutesByTo { '/projects': typeof AppProjectsRoute '/support': typeof AppSupportRoute '/team': typeof AppTeamRoute + '/setup': typeof AuthSetupRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute '/subscribe': typeof AuthenticatedSubscribeRoute '/accept-invitation/$id': typeof AcceptInvitationIdRoute '/api/health': typeof ApiHealthRoute + '/api/team-setup': typeof ApiTeamSetupRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute '/settings/organization': typeof AppSettingsOrganizationRoute + '/settings/users': typeof AppSettingsUsersRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute @@ -449,17 +473,20 @@ export interface FileRoutesById { '/_app/settings': typeof AppSettingsRouteWithChildren '/_app/support': typeof AppSupportRoute '/_app/team': typeof AppTeamRoute + '/_auth/setup': typeof AuthSetupRoute '/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-up': typeof AuthSignUpRoute '/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute '/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute '/accept-invitation/$id': typeof AcceptInvitationIdRoute '/api/health': typeof ApiHealthRoute + '/api/team-setup': typeof ApiTeamSetupRoute '/_app/': typeof AppIndexRoute '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute '/_app/settings/organization': typeof AppSettingsOrganizationRoute + '/_app/settings/users': typeof AppSettingsUsersRoute '/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute @@ -502,16 +529,19 @@ export interface FileRouteTypes { | '/settings' | '/support' | '/team' + | '/setup' | '/sign-in' | '/sign-up' | '/oauth-consent' | '/subscribe' | '/accept-invitation/$id' | '/api/health' + | '/api/team-setup' | '/p/$projectId' | '/help/dataforseo-api-key' | '/help/openrouter-api-key' | '/settings/organization' + | '/settings/users' | '/onboarding/chat' | '/api/auth/$' | '/api/autumn/$' @@ -551,15 +581,18 @@ export interface FileRouteTypes { | '/projects' | '/support' | '/team' + | '/setup' | '/sign-in' | '/sign-up' | '/oauth-consent' | '/subscribe' | '/accept-invitation/$id' | '/api/health' + | '/api/team-setup' | '/help/dataforseo-api-key' | '/help/openrouter-api-key' | '/settings/organization' + | '/settings/users' | '/onboarding/chat' | '/api/auth/$' | '/api/autumn/$' @@ -600,17 +633,20 @@ export interface FileRouteTypes { | '/_app/settings' | '/_app/support' | '/_app/team' + | '/_auth/setup' | '/_auth/sign-in' | '/_auth/sign-up' | '/_authenticated/oauth-consent' | '/_authenticated/subscribe' | '/accept-invitation/$id' | '/api/health' + | '/api/team-setup' | '/_app/' | '/_project/p/$projectId' | '/_app/help/dataforseo-api-key' | '/_app/help/openrouter-api-key' | '/_app/settings/organization' + | '/_app/settings/users' | '/_authenticated/onboarding/chat' | '/api/auth/$' | '/api/autumn/$' @@ -651,6 +687,7 @@ export interface RootRouteChildren { Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute AcceptInvitationIdRoute: typeof AcceptInvitationIdRoute ApiHealthRoute: typeof ApiHealthRoute + ApiTeamSetupRoute: typeof ApiTeamSetupRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute @@ -722,6 +759,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppIndexRouteImport parentRoute: typeof AppRouteRoute } + '/api/team-setup': { + id: '/api/team-setup' + path: '/api/team-setup' + fullPath: '/api/team-setup' + preLoaderRoute: typeof ApiTeamSetupRouteImport + parentRoute: typeof rootRouteImport + } '/api/health': { id: '/api/health' path: '/api/health' @@ -764,6 +808,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthSignInRouteImport parentRoute: typeof AuthRoute } + '/_auth/setup': { + id: '/_auth/setup' + path: '/setup' + fullPath: '/setup' + preLoaderRoute: typeof AuthSetupRouteImport + parentRoute: typeof AuthRoute + } '/_app/team': { id: '/_app/team' path: '/team' @@ -848,6 +899,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport parentRoute: typeof AuthenticatedRoute } + '/_app/settings/users': { + id: '/_app/settings/users' + path: '/users' + fullPath: '/settings/users' + preLoaderRoute: typeof AppSettingsUsersRouteImport + parentRoute: typeof AppSettingsRoute + } '/_app/settings/organization': { id: '/_app/settings/organization' path: '/organization' @@ -1028,11 +1086,13 @@ declare module '@tanstack/react-router' { interface AppSettingsRouteChildren { AppSettingsOrganizationRoute: typeof AppSettingsOrganizationRoute + AppSettingsUsersRoute: typeof AppSettingsUsersRoute AppSettingsIndexRoute: typeof AppSettingsIndexRoute } const AppSettingsRouteChildren: AppSettingsRouteChildren = { AppSettingsOrganizationRoute: AppSettingsOrganizationRoute, + AppSettingsUsersRoute: AppSettingsUsersRoute, AppSettingsIndexRoute: AppSettingsIndexRoute, } @@ -1174,11 +1234,13 @@ const ProjectRouteRouteWithChildren = ProjectRouteRoute._addFileChildren( ) interface AuthRouteChildren { + AuthSetupRoute: typeof AuthSetupRoute AuthSignInRoute: typeof AuthSignInRoute AuthSignUpRoute: typeof AuthSignUpRoute } const AuthRouteChildren: AuthRouteChildren = { + AuthSetupRoute: AuthSetupRoute, AuthSignInRoute: AuthSignInRoute, AuthSignUpRoute: AuthSignUpRoute, } @@ -1216,6 +1278,7 @@ const rootRouteChildren: RootRouteChildren = { Char91DotwellKnownChar93OpenaiAppsChallengeRoute, AcceptInvitationIdRoute: AcceptInvitationIdRoute, ApiHealthRoute: ApiHealthRoute, + ApiTeamSetupRoute: ApiTeamSetupRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute, ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute, diff --git a/src/routes/_app/settings.tsx b/src/routes/_app/settings.tsx index 01b9632..c27751b 100644 --- a/src/routes/_app/settings.tsx +++ b/src/routes/_app/settings.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link, Outlet } from "@tanstack/react-router"; -import { isSessionClientAuthMode } from "@/lib/auth-mode"; +import { isSessionClientAuthMode, isTeamClientAuthMode } from "@/lib/auth-mode"; export const Route = createFileRoute("/_app/settings")({ component: SettingsLayout, @@ -15,6 +15,10 @@ function SettingsLayout() { ...(isSessionClientAuthMode() ? [{ to: "/settings/organization" as const, label: "Organization" }] : []), + // `team` mode adds direct account provisioning alongside invitations. + ...(isTeamClientAuthMode() + ? [{ to: "/settings/users" as const, label: "Users" }] + : []), ]; return ( diff --git a/src/routes/_app/settings/users.tsx b/src/routes/_app/settings/users.tsx new file mode 100644 index 0000000..b85c95d --- /dev/null +++ b/src/routes/_app/settings/users.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { TeamUsers } from "@/client/features/team/TeamUsers"; +import { isTeamClientAuthMode } from "@/lib/auth-mode"; + +export const Route = createFileRoute("/_app/settings/users")({ + // Direct account provisioning only exists in `team` mode. + beforeLoad: () => { + if (!isTeamClientAuthMode()) { + throw notFound(); + } + }, + component: TeamUsers, +}); diff --git a/src/routes/_auth.setup.tsx b/src/routes/_auth.setup.tsx new file mode 100644 index 0000000..6f0fd06 --- /dev/null +++ b/src/routes/_auth.setup.tsx @@ -0,0 +1,105 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { AuthPageCard } from "@/client/features/auth/AuthPage"; +import { + bootstrapTeamOwner, + fetchTeamSetupStatus, +} from "@/client/features/auth/teamSetup"; +import { authClient } from "@/lib/auth-client"; + +// First-run screen for `team` mode: creates the single owner account. It +// redirects to /sign-in the moment an owner exists, so it can't be used to add +// more users. +export const Route = createFileRoute("/_auth/setup")({ + beforeLoad: async () => { + const status = await fetchTeamSetupStatus(); + if (!status.needsOwner) { + throw redirect({ to: "/sign-in", search: {} }); + } + }, + component: SetupPage, +}); + +function SetupPage() { + const navigate = useNavigate(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + try { + await bootstrapTeamOwner({ name, email, password }); + const result = await authClient.signIn.email({ + email: email.trim(), + password, + callbackURL: "/", + }); + if (result.error) { + // Account created but auto sign-in failed — send them to sign in + // manually rather than leaving them stuck here. + void navigate({ to: "/sign-in", search: {} }); + return; + } + // Full reload so every query and the router start from the new session. + window.location.assign("/"); + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : "We couldn't complete setup.", + ); + setIsSubmitting(false); + } + } + + return ( + +
+ setName(event.target.value)} + autoComplete="name" + required + /> + setEmail(event.target.value)} + autoComplete="username" + required + /> + setPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> + {error ?

{error}

: null} + +
+
+ ); +} diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx index 22600bc..e3008b1 100644 --- a/src/routes/_auth.sign-in.tsx +++ b/src/routes/_auth.sign-in.tsx @@ -1,12 +1,13 @@ import { useForm } from "@tanstack/react-form"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { AuthPageCard, AuthMethodChooser, authRedirectSearchSchema, useAuthPageState, } from "@/client/features/auth/AuthPage"; +import { fetchTeamSetupStatus } from "@/client/features/auth/teamSetup"; import { getFieldError, getFormError } from "@/client/lib/forms"; import { captureClientEvent } from "@/client/lib/posthog"; import { authClient } from "@/lib/auth-client"; @@ -31,6 +32,20 @@ function SignInPage() { const authCallbackURL = redirectTo; // `team` mode has no Google button, so go straight to the email/password form. const [showEmailForm, setShowEmailForm] = useState(!isHostedMode); + + // `team` mode with no owner yet: send them to the one-time setup screen. + useEffect(() => { + if (!isSessionMode) return; + let cancelled = false; + void fetchTeamSetupStatus().then((status) => { + if (!cancelled && status.needsOwner) { + void navigate({ to: "/setup", search: {} }); + } + }); + return () => { + cancelled = true; + }; + }, [isSessionMode, navigate]); const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [socialError, setSocialError] = useState(null); diff --git a/src/routes/api/team-setup.ts b/src/routes/api/team-setup.ts new file mode 100644 index 0000000..6d207ca --- /dev/null +++ b/src/routes/api/team-setup.ts @@ -0,0 +1,102 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import { z } from "zod"; +import { isTeamAuthMode } from "@/lib/auth-mode"; +import { + HOSTED_PASSWORD_MAX_LENGTH, + HOSTED_PASSWORD_MIN_LENGTH, +} from "@/lib/auth-options"; +import { + countUsers, + createSharedOrganization, + getSharedOrganizationId, + provisionTeamMember, +} from "@/server/features/team/teamProvisioning"; + +// Unauthenticated setup endpoint for `team` mode: it exists only to create the +// very first (owner) account, and disables itself the moment a user exists. All +// other user management goes through the owner-gated team-users server +// functions. It sits outside ensureUserMiddleware (raw API route) because there +// is no session yet when the owner is created. + +async function handleStatus(): Promise { + if (!isTeamAuthMode(env.AUTH_MODE)) { + return Response.json({ mode: "other", needsOwner: false }); + } + return Response.json({ + mode: "team", + needsOwner: (await countUsers()) === 0, + }); +} + +const bootstrapSchema = z.object({ + email: z.string().trim().email(), + name: z.string().trim().min(1).max(120), + password: z + .string() + .min(HOSTED_PASSWORD_MIN_LENGTH) + .max(HOSTED_PASSWORD_MAX_LENGTH), +}); + +async function handleBootstrap(request: Request): Promise { + if (!isTeamAuthMode(env.AUTH_MODE)) { + return new Response("Not found", { status: 404 }); + } + + // Self-disabling: once any user exists, the owner already ran this. + if ((await countUsers()) > 0) { + return Response.json( + { error: "This workspace already has an owner. Sign in instead." }, + { status: 409 }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid request body." }, { status: 400 }); + } + + const parsed = bootstrapSchema.safeParse(body); + if (!parsed.success) { + return Response.json( + { + error: `Enter a name, a valid email, and a password ${HOSTED_PASSWORD_MIN_LENGTH}-${HOSTED_PASSWORD_MAX_LENGTH} characters long.`, + }, + { status: 400 }, + ); + } + + const organizationId = + (await getSharedOrganizationId()) ?? (await createSharedOrganization()); + + try { + await provisionTeamMember({ + ...parsed.data, + role: "owner", + organizationId, + }); + } catch (error) { + return Response.json( + { + error: + error instanceof Error + ? error.message + : "Could not create the owner account.", + }, + { status: 400 }, + ); + } + + return Response.json({ ok: true }); +} + +export const Route = createFileRoute("/api/team-setup")({ + server: { + handlers: { + GET: () => handleStatus(), + POST: ({ request }: { request: Request }) => handleBootstrap(request), + }, + }, +}); diff --git a/src/server/auth/repositories/AuthRepository.ts b/src/server/auth/repositories/AuthRepository.ts index 64292f2..bebccbe 100644 --- a/src/server/auth/repositories/AuthRepository.ts +++ b/src/server/auth/repositories/AuthRepository.ts @@ -137,6 +137,31 @@ async function listMembershipsForUser(userId: string) { .orderBy(asc(member.createdAt)); } +// Roster for the team-users admin screen: every member of one organization +// with the identity fields the UI shows. +async function listOrganizationMembers(organizationId: string) { + return db + .select({ + userId: member.userId, + email: authUser.email, + name: authUser.name, + role: member.role, + joinedAt: member.createdAt, + }) + .from(member) + .innerJoin(authUser, eq(member.userId, authUser.id)) + .where(eq(member.organizationId, organizationId)) + .orderBy(asc(member.createdAt)); +} + +async function removeMembership(userId: string, organizationId: string) { + await db + .delete(member) + .where( + and(eq(member.userId, userId), eq(member.organizationId, organizationId)), + ); +} + // Case-insensitive on purpose: better-auth lowercases the address when it // mails an invite, but the stored row keeps whatever the inviter typed. async function hasPendingInvitationForEmail(email: string) { @@ -181,6 +206,8 @@ export const AuthRepository = { findNewestMembershipForUser, getMembership, listMembershipsForUser, + listOrganizationMembers, + removeMembership, getLastActiveOrganizationId, setLastActiveOrganization, getHostedUser, diff --git a/src/server/features/team/teamProvisioning.test.ts b/src/server/features/team/teamProvisioning.test.ts new file mode 100644 index 0000000..2ebb099 --- /dev/null +++ b/src/server/features/team/teamProvisioning.test.ts @@ -0,0 +1,145 @@ +import { createClient, type Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import type * as TeamProvisioningModule from "./teamProvisioning"; + +// Real in-memory SQLite: provisionTeamMember writes user + credential account + +// member in one shot, and the duplicate-email guard is the invariant that keeps +// the owner from silently creating a second unusable row. + +vi.mock("cloudflare:workers", () => ({ env: { DATABASE_PROVIDER: "d1" } })); + +let client: Client; +let mod: typeof TeamProvisioningModule; + +async function rows(sql: string) { + return (await client.execute(sql)).rows; +} + +beforeAll(async () => { + client = createClient({ url: "file::memory:" }); + const testDb = drizzle(client); + vi.doMock("@/db", () => ({ db: testDb })); + vi.doMock("@/db/d1/client", () => ({ d1Db: testDb })); + vi.doMock("@/db/pg/client", () => ({ pgDb: null })); + + await client.executeMultiple(` + CREATE TABLE organization (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL, logo TEXT, created_at INTEGER NOT NULL, metadata TEXT); + CREATE TABLE "user" (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, email_verified INTEGER NOT NULL DEFAULT 0, image TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, analytics_opted_out INTEGER, last_active_organization_id TEXT); + CREATE TABLE account (id TEXT PRIMARY KEY, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL, access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, password TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); + CREATE TABLE member (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', created_at INTEGER NOT NULL); + CREATE TABLE session (id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, token TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, user_id TEXT NOT NULL, active_organization_id TEXT); + `); + + mod = await import("./teamProvisioning"); +}); + +afterAll(() => { + client.close(); +}); + +beforeEach(async () => { + await client.executeMultiple(` + DELETE FROM member; DELETE FROM account; DELETE FROM "user"; DELETE FROM organization; DELETE FROM session; + `); +}); + +describe("teamProvisioning", () => { + it("counts users and finds the oldest organization as the shared workspace", async () => { + expect(await mod.countUsers()).toBe(0); + expect(await mod.getSharedOrganizationId()).toBeNull(); + + const orgId = await mod.createSharedOrganization("CrawlerX"); + expect(await mod.getSharedOrganizationId()).toBe(orgId); + }); + + it("writes user, credential account, and membership together", async () => { + const orgId = await mod.createSharedOrganization(); + const { userId } = await mod.provisionTeamMember({ + email: "Owner@Example.com", + name: " Ada ", + password: "correct-horse", + role: "owner", + organizationId: orgId, + }); + + const [userRow] = await rows( + `SELECT email, name, email_verified FROM "user"`, + ); + expect(userRow.email).toBe("owner@example.com"); + expect(userRow.name).toBe("Ada"); + expect(userRow.email_verified).toBe(1); + + const [accountRow] = await rows( + `SELECT provider_id, account_id, password FROM account`, + ); + expect(accountRow.provider_id).toBe("credential"); + expect(accountRow.account_id).toBe(userId); + expect(accountRow.password).toBeTruthy(); + + const [memberRow] = await rows(`SELECT role, organization_id FROM member`); + expect(memberRow.role).toBe("owner"); + expect(memberRow.organization_id).toBe(orgId); + + expect(await mod.countUsers()).toBe(1); + }); + + it("rejects a duplicate email case-insensitively", async () => { + const orgId = await mod.createSharedOrganization(); + await mod.provisionTeamMember({ + email: "dup@example.com", + name: "First", + password: "password-one", + role: "member", + organizationId: orgId, + }); + + await expect( + mod.provisionTeamMember({ + email: "DUP@example.com", + name: "Second", + password: "password-two", + role: "member", + organizationId: orgId, + }), + ).rejects.toThrow(/already exists/i); + + expect(await mod.countUsers()).toBe(1); + }); + + it("resets the credential password and revokes sessions", async () => { + const orgId = await mod.createSharedOrganization(); + const { userId } = await mod.provisionTeamMember({ + email: "member@example.com", + name: "Mem", + password: "old-password", + role: "member", + organizationId: orgId, + }); + await client.execute( + `INSERT INTO session (id, expires_at, token, created_at, updated_at, user_id) VALUES ('s1', 0, 't1', 0, 0, '${userId}')`, + ); + + const [before] = await rows( + `SELECT password FROM account WHERE user_id = '${userId}'`, + ); + await mod.setCredentialPassword(userId, "brand-new-password"); + const [after] = await rows( + `SELECT password FROM account WHERE user_id = '${userId}'`, + ); + expect(after.password).not.toBe(before.password); + + await mod.revokeUserSessions(userId); + expect( + await rows(`SELECT id FROM session WHERE user_id = '${userId}'`), + ).toHaveLength(0); + }); +}); diff --git a/src/server/features/team/teamProvisioning.ts b/src/server/features/team/teamProvisioning.ts new file mode 100644 index 0000000..9118b09 --- /dev/null +++ b/src/server/features/team/teamProvisioning.ts @@ -0,0 +1,130 @@ +import { randomUUID } from "node:crypto"; +import { hashPassword } from "better-auth/crypto"; +import { count, eq, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { account, member, organization, session, user } from "@/db/schema"; +import { slugify, toHex } from "@/server/auth/org-slug"; + +// `team` mode runs one shared workspace. There is no self-serve signup: the +// first owner is created through /api/team-setup, and every teammate after that +// through the owner-gated team-users server functions. Both paths funnel through +// `provisionTeamMember` so the user + credential account + membership rows are +// always written together. + +const TEAM_DEFAULT_ORG_NAME = "CrawlerX"; + +type TeamMemberRole = "owner" | "admin" | "member"; + +export async function countUsers(): Promise { + const [row] = await db.select({ value: count() }).from(user); + return row?.value ?? 0; +} + +// The shared workspace is simply the oldest organization row — `team` mode +// never creates more than one. +export async function getSharedOrganizationId(): Promise { + const [row] = await db + .select({ id: organization.id }) + .from(organization) + .orderBy(organization.createdAt) + .limit(1); + return row?.id ?? null; +} + +export async function createSharedOrganization( + name: string = TEAM_DEFAULT_ORG_NAME, +): Promise { + const id = randomUUID(); + await db.insert(organization).values({ + id, + name, + slug: `${slugify(name)}-${toHex(id).slice(0, 8)}`, + logo: null, + createdAt: new Date(), + metadata: null, + }); + return id; +} + +async function findUserIdByEmail(email: string): Promise { + const [row] = await db + .select({ id: user.id }) + .from(user) + .where(eq(sql`lower(${user.email})`, email.trim().toLowerCase())) + .limit(1); + return row?.id ?? null; +} + +type ProvisionInput = { + email: string; + name: string; + password: string; + role: TeamMemberRole; + organizationId: string; +}; + +export async function provisionTeamMember( + input: ProvisionInput, +): Promise<{ userId: string; email: string }> { + const email = input.email.trim().toLowerCase(); + + if (await findUserIdByEmail(email)) { + throw new Error("A user with that email already exists."); + } + + const now = new Date(); + const userId = randomUUID(); + const passwordHash = await hashPassword(input.password); + + // No transaction wrapper: the provider-aware `db` must stay D1-compatible and + // these three inserts are only ever driven by a single admin action. A + // partial write (e.g. account insert fails) leaves an orphan user row with no + // way to sign in, which the owner can delete and recreate. + await db.insert(user).values({ + id: userId, + name: input.name.trim() || email.split("@")[0] || "Teammate", + email, + emailVerified: true, + createdAt: now, + updatedAt: now, + analyticsOptedOut: false, + lastActiveOrganizationId: input.organizationId, + }); + + await db.insert(account).values({ + id: randomUUID(), + accountId: userId, + providerId: "credential", + userId, + password: passwordHash, + createdAt: now, + updatedAt: now, + }); + + await db.insert(member).values({ + id: randomUUID(), + organizationId: input.organizationId, + userId, + role: input.role, + createdAt: now, + }); + + return { userId, email }; +} + +export async function setCredentialPassword( + userId: string, + password: string, +): Promise { + const passwordHash = await hashPassword(password); + await db + .update(account) + .set({ password: passwordHash, updatedAt: new Date() }) + .where( + sql`${account.userId} = ${userId} and ${account.providerId} = 'credential'`, + ); +} + +export async function revokeUserSessions(userId: string): Promise { + await db.delete(session).where(eq(session.userId, userId)); +} diff --git a/src/serverFunctions/teamUsers.ts b/src/serverFunctions/teamUsers.ts new file mode 100644 index 0000000..da47255 --- /dev/null +++ b/src/serverFunctions/teamUsers.ts @@ -0,0 +1,151 @@ +import { env } from "cloudflare:workers"; +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { isTeamAuthMode } from "@/lib/auth-mode"; +import { + HOSTED_PASSWORD_MAX_LENGTH, + HOSTED_PASSWORD_MIN_LENGTH, +} from "@/lib/auth-options"; +import { AuthRepository } from "@/server/auth/repositories/AuthRepository"; +import { requireOrgPermission } from "@/server/auth/org-gate"; +import { AppError } from "@/server/lib/errors"; +import { + provisionTeamMember, + revokeUserSessions, + setCredentialPassword, +} from "@/server/features/team/teamProvisioning"; +import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; + +// Owner/admin-only team management for `team` mode. These never run in the +// hosted SaaS (which manages members through invitations + billing) or the +// delegated modes (no member rows) — the guard makes that explicit rather than +// relying on the UI to hide the screen. + +function requireTeamMode() { + if (!isTeamAuthMode(env.AUTH_MODE)) { + throw new AppError("NOT_FOUND", "Team user management is not enabled."); + } +} + +const passwordSchema = z + .string() + .min(HOSTED_PASSWORD_MIN_LENGTH) + .max(HOSTED_PASSWORD_MAX_LENGTH); + +const createTeamUserSchema = z.object({ + email: z.string().trim().email(), + name: z.string().trim().min(1).max(120), + password: passwordSchema, + role: z.enum(["admin", "member"]), +}); + +const resetPasswordSchema = z.object({ + userId: z.string().min(1), + password: passwordSchema, +}); + +const removeUserSchema = z.object({ userId: z.string().min(1) }); + +function roleList(role: string) { + return role.split(",").map((part) => part.trim()); +} + +export const listTeamUsers = createServerFn({ method: "POST" }) + .middleware(requireAuthenticatedContext) + .handler(async ({ context }) => { + requireTeamMode(); + requireOrgPermission(context, { member: ["create"] }); + + const members = await AuthRepository.listOrganizationMembers( + context.organizationId, + ); + + return members.map((entry) => ({ + userId: entry.userId, + email: entry.email, + name: entry.name, + role: entry.role, + joinedAt: + entry.joinedAt instanceof Date + ? entry.joinedAt.toISOString() + : new Date(entry.joinedAt).toISOString(), + isSelf: entry.userId === context.userId, + isOwner: roleList(entry.role).includes("owner"), + })); + }); + +export const createTeamUser = createServerFn({ method: "POST" }) + .middleware(requireAuthenticatedContext) + .validator(createTeamUserSchema) + .handler(async ({ data, context }) => { + requireTeamMode(); + requireOrgPermission(context, { member: ["create"] }); + + try { + const created = await provisionTeamMember({ + email: data.email, + name: data.name, + password: data.password, + role: data.role, + organizationId: context.organizationId, + }); + return { userId: created.userId, email: created.email }; + } catch (error) { + throw new AppError( + "VALIDATION_ERROR", + error instanceof Error + ? error.message + : "Could not create the account.", + ); + } + }); + +export const resetTeamUserPassword = createServerFn({ method: "POST" }) + .middleware(requireAuthenticatedContext) + .validator(resetPasswordSchema) + .handler(async ({ data, context }) => { + requireTeamMode(); + requireOrgPermission(context, { member: ["create"] }); + + const membership = await AuthRepository.getMembership( + data.userId, + context.organizationId, + ); + if (!membership) { + throw new AppError("NOT_FOUND", "That user is not in this workspace."); + } + + await setCredentialPassword(data.userId, data.password); + await revokeUserSessions(data.userId); + return { ok: true }; + }); + +export const removeTeamUser = createServerFn({ method: "POST" }) + .middleware(requireAuthenticatedContext) + .validator(removeUserSchema) + .handler(async ({ data, context }) => { + requireTeamMode(); + requireOrgPermission(context, { member: ["delete"] }); + + if (data.userId === context.userId) { + throw new AppError("FORBIDDEN", "You can't remove your own account."); + } + + const membership = await AuthRepository.getMembership( + data.userId, + context.organizationId, + ); + if (!membership) { + throw new AppError("NOT_FOUND", "That user is not in this workspace."); + } + if (roleList(membership.role).includes("owner")) { + throw new AppError("FORBIDDEN", "The workspace owner can't be removed."); + } + + // Soft removal: drop the membership and kill live sessions. The user row is + // kept so historical references (activity, audits) still resolve; the owner + // can re-add them later with a fresh password. + await AuthRepository.removeMembership(data.userId, context.organizationId); + await revokeUserSessions(data.userId); + return { ok: true }; + });