Three pieces of authentication work that need to land together so OAuth sign-ins produce a usable session. * `ensureUserIdByEmail` upserts a `users` row on every OAuth sign-in matched case-insensitively on email, then stamps `token.id` with the resulting UUID so workspace-scoped tRPC procedures can resolve membership. Credentials sign-in already returned the DB id from `authorize`; OAuth now does the equivalent. * `ensureUserHasWorkspace` mints a personal workspace (and `owner` member row) on first sign-in for any user that doesn't already belong to one, so fresh OAuth accounts don't land in the app with no tenant scope. Idempotent; slug collisions retry with a random suffix and cap at 5 attempts. * Migration 0004 adds a `UNIQUE (lower(email))` index on `users` to match the lookup pattern and prevent two providers from minting rows that differ only in casing. Existing rows are normalized to lowercase first; the column-level UNIQUE catches any pre-existing duplicates so they get resolved by a human rather than silently merged. Sign-in / sign-up pages add an Authentik SSO button (gated on `AUTH_AUTHENTIK_*` env vars). Layout switches to GitHub+Google on top with Authentik full-width below. Co-authored-by: Cursor <cursoragent@cursor.com>
170 lines
6.5 KiB
TypeScript
170 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { signIn } from "next-auth/react";
|
|
import { Loader2, Lock, Mail, ShieldCheck, Sparkles, User } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function SignUpPage() {
|
|
const [name, setName] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
|
|
async function onSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setMessage(null);
|
|
setLoading(true);
|
|
try {
|
|
await new Promise((r) => setTimeout(r, 600));
|
|
setMessage("Thanks — account creation will connect to your API soon. For now, use dev sign-in.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-2xl border border-border/60 bg-card/80 p-8 shadow-xl shadow-[hsl(var(--teal)/0.06)] backdrop-blur-md",
|
|
"ring-1 ring-[hsl(var(--teal)/0.15)]",
|
|
)}
|
|
>
|
|
<div className="mb-8 text-center">
|
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[hsl(var(--teal))] to-[hsl(var(--primary))] text-primary-foreground shadow-lg">
|
|
<Sparkles className="h-6 w-6" aria-hidden />
|
|
</div>
|
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Join Tasks</h1>
|
|
<p className="mt-2 text-sm text-muted-foreground">Create your workspace — registration is a preview for now.</p>
|
|
</div>
|
|
|
|
<form onSubmit={onSubmit} className="space-y-5">
|
|
{message ? (
|
|
<div
|
|
role="status"
|
|
className="rounded-lg border border-[hsl(var(--teal)/0.35)] bg-[hsl(var(--teal)/0.08)] px-3 py-2 text-sm text-foreground"
|
|
>
|
|
{message}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="name" className="text-sm font-medium text-foreground">
|
|
Name
|
|
</label>
|
|
<div className="relative">
|
|
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
autoComplete="name"
|
|
required
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className={cn(
|
|
"w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-3 text-sm outline-none transition",
|
|
"placeholder:text-muted-foreground focus:border-[hsl(var(--teal))] focus:ring-2 focus:ring-[hsl(var(--teal)/0.25)]",
|
|
)}
|
|
placeholder="Alex Doe"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="email" className="text-sm font-medium text-foreground">
|
|
Email
|
|
</label>
|
|
<div className="relative">
|
|
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className={cn(
|
|
"w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-3 text-sm outline-none transition",
|
|
"placeholder:text-muted-foreground focus:border-[hsl(var(--teal))] focus:ring-2 focus:ring-[hsl(var(--teal)/0.25)]",
|
|
)}
|
|
placeholder="you@example.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="password" className="text-sm font-medium text-foreground">
|
|
Password
|
|
</label>
|
|
<div className="relative">
|
|
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
required
|
|
minLength={8}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className={cn(
|
|
"w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-3 text-sm outline-none transition",
|
|
"placeholder:text-muted-foreground focus:border-[hsl(var(--teal))] focus:ring-2 focus:ring-[hsl(var(--teal)/0.25)]",
|
|
)}
|
|
placeholder="At least 8 characters"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className={cn(
|
|
"flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-medium text-primary-foreground transition",
|
|
"bg-gradient-to-r from-[hsl(var(--primary))] to-[hsl(var(--teal))] hover:opacity-[0.96]",
|
|
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[hsl(var(--primary))]",
|
|
"disabled:pointer-events-none disabled:opacity-60",
|
|
)}
|
|
>
|
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
Create account
|
|
</button>
|
|
</form>
|
|
|
|
<div className="relative my-8">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<span className="w-full border-t border-border" />
|
|
</div>
|
|
<div className="relative flex justify-center text-xs uppercase tracking-wide">
|
|
<span className="bg-card/90 px-2 text-muted-foreground">Or use single sign-on</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => void signIn("authentik", { callbackUrl: "/" })}
|
|
className={cn(
|
|
"flex w-full items-center justify-center gap-2 rounded-lg border border-border bg-background py-2.5 text-sm font-medium transition",
|
|
"hover:border-[hsl(var(--primary)/0.4)] hover:bg-muted/50",
|
|
)}
|
|
>
|
|
<ShieldCheck className="h-4 w-4 text-[hsl(var(--primary))]" aria-hidden />
|
|
Continue with SSO
|
|
</button>
|
|
|
|
<p className="mt-8 text-center text-sm text-muted-foreground">
|
|
Already have an account?{" "}
|
|
<Link
|
|
href="/sign-in"
|
|
className="font-medium text-[hsl(var(--primary))] underline-offset-4 hover:underline"
|
|
>
|
|
Sign in
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|