Phase 3: team-mode owner setup, user management, shared workspace
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:
parent
c47b032f1a
commit
acc35c055c
79
docs/SELF_HOSTING_TEAM_MODE.md
Normal file
79
docs/SELF_HOSTING_TEAM_MODE.md
Normal 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.
|
||||
45
src/client/features/auth/teamSetup.ts
Normal file
45
src/client/features/auth/teamSetup.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
345
src/client/features/team/TeamUsers.tsx
Normal file
345
src/client/features/team/TeamUsers.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
51
src/middleware/ensure-user/team.ts
Normal file
51
src/middleware/ensure-user/team.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
13
src/routes/_app/settings/users.tsx
Normal file
13
src/routes/_app/settings/users.tsx
Normal 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
105
src/routes/_auth.setup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
102
src/routes/api/team-setup.ts
Normal file
102
src/routes/api/team-setup.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
|
||||
145
src/server/features/team/teamProvisioning.test.ts
Normal file
145
src/server/features/team/teamProvisioning.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
src/server/features/team/teamProvisioning.ts
Normal file
130
src/server/features/team/teamProvisioning.ts
Normal 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));
|
||||
}
|
||||
151
src/serverFunctions/teamUsers.ts
Normal file
151
src/serverFunctions/teamUsers.ts
Normal 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 };
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user