Phase 3: team-mode owner setup, user management, shared workspace
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-09 08:13:01 -04:00
parent c47b032f1a
commit acc35c055c
16 changed files with 1287 additions and 7 deletions

View File

@ -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.

View File

@ -0,0 +1,45 @@
import { z } from "zod";
const statusSchema = z.object({
mode: z.enum(["team", "other"]),
needsOwner: z.boolean(),
});
type TeamSetupStatus = z.infer<typeof statusSchema>;
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<TeamSetupStatus> {
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<void> {
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.");
}
}

View File

@ -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<ReturnType<typeof listTeamUsers>>[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<TeamUser | null>(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 (
<div className="space-y-3">
<p className="text-sm text-base-content/70">
We couldn&rsquo;t load the user list.
</p>
<button
type="button"
className="btn btn-soft btn-sm"
onClick={() => void usersQuery.refetch()}
>
Try again
</button>
</div>
);
}
const users = usersQuery.data ?? [];
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-medium text-base-content/50">Users</h2>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => setIsAddOpen(true)}
>
Add user
</button>
</div>
<p className="text-sm text-base-content/60">
Create a sign-in for each teammate. Share the email and temporary
password; they can change it after signing in.
</p>
{usersQuery.isPending ? (
<div className="flex justify-center py-6">
<span className="loading loading-spinner loading-md" />
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-base-300">
<table className="table table-sm">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Added</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.userId}>
<td>
<div className="font-medium">{user.name}</div>
<div className="text-xs text-base-content/60">
{user.email}
</div>
</td>
<td className="capitalize">{user.role}</td>
<td className="text-xs text-base-content/60">
{new Date(user.joinedAt).toLocaleDateString()}
</td>
<td className="text-right">
{user.isOwner ? null : (
<div className="flex justify-end gap-1">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => setResetFor(user)}
>
Reset password
</button>
{user.isSelf ? null : (
<button
type="button"
className="btn btn-ghost btn-xs text-error"
disabled={removeMutation.isPending}
onClick={() => {
if (
window.confirm(
`Remove ${user.email} from the workspace?`,
)
) {
removeMutation.mutate(user.userId);
}
}}
>
Remove
</button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{isAddOpen ? (
<AddUserModal
onClose={() => setIsAddOpen(false)}
onCreated={() => {
setIsAddOpen(false);
void refresh();
}}
/>
) : null}
{resetFor ? (
<ResetPasswordModal
user={resetFor}
onClose={() => setResetFor(null)}
onDone={() => setResetFor(null)}
/>
) : null}
</section>
);
}
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 (
<ModalShell title="Add user" onClose={onClose}>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate();
}}
>
<input
type="text"
className="input input-bordered w-full"
placeholder="Full name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
<input
type="email"
className="input input-bordered w-full"
placeholder="Email address"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<input
type="text"
className="input input-bordered w-full"
placeholder="Temporary password (min 8 characters)"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
<select
className="select select-bordered w-full"
value={role}
onChange={(event) =>
setRole(event.target.value === "admin" ? "admin" : "member")
}
>
<option value="member">Member research + view</option>
<option value="admin">Admin full access except billing</option>
</select>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={mutation.isPending}
>
{mutation.isPending ? "Creating…" : "Create user"}
</button>
</div>
</form>
</ModalShell>
);
}
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 (
<ModalShell title={`Reset password — ${user.email}`} onClose={onClose}>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate();
}}
>
<p className="text-sm text-base-content/60">
Sets a new password and signs the user out of all devices.
</p>
<input
type="text"
className="input input-bordered w-full"
placeholder="New password (min 8 characters)"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={mutation.isPending}
>
{mutation.isPending ? "Saving…" : "Reset password"}
</button>
</div>
</form>
</ModalShell>
);
}
function ModalShell({
title,
onClose,
children,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-sm rounded-box border border-base-300 bg-base-100 p-5 shadow-xl">
<div className="mb-3 flex items-start justify-between gap-4">
<h3 className="font-semibold">{title}</h3>
<button
type="button"
className="btn btn-ghost btn-xs btn-circle"
aria-label="Close"
onClick={onClose}
>
</button>
</div>
{children}
</div>
</div>
);
}

View File

@ -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

View File

@ -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);

View File

@ -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<EnsuredUserContext> {
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,
};
}

View File

@ -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,

View File

@ -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 (

View File

@ -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,
});

105
src/routes/_auth.setup.tsx Normal file
View File

@ -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<string | null>(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 (
<AuthPageCard
title="Create the owner account"
helperText="This is the first and only owner of the workspace. Add teammates afterwards from Settings → Users."
>
<form className="space-y-3" onSubmit={handleSubmit}>
<input
type="text"
className="input input-bordered w-full"
placeholder="Full name"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
required
/>
<input
type="email"
className="input input-bordered w-full"
placeholder="Email address"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="username"
required
/>
<input
type="password"
className="input input-bordered w-full"
placeholder="Password (min 8 characters)"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
{error ? <p className="text-sm text-error">{error}</p> : null}
<button
type="submit"
className="btn btn-primary w-full"
disabled={isSubmitting}
>
{isSubmitting ? "Setting up…" : "Create workspace"}
</button>
</form>
</AuthPageCard>
);
}

View File

@ -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<string | null>(null);

View File

@ -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<Response> {
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<Response> {
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),
},
},
});

View File

@ -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,

View File

@ -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);
});
});

View File

@ -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<number> {
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<string | null> {
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<string> {
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<string | null> {
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<void> {
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<void> {
await db.delete(session).where(eq(session.userId, userId));
}

View File

@ -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 };
});