Skip to content

Commit c36eca6

Browse files
feat: auth flow — sign up, sign in, sign out (#24)
- Add (auth) route group with /sign-in and /sign-up pages - Add (app) route group with auth guard redirecting to /sign-in - Sign-up passes display_name to handle_new_user trigger - Post-auth redirect to user's personal workspace (/[workspaceSlug]) - Sign-out clears session and redirects to /sign-in - GitHub and Google OAuth buttons rendered disabled with 'coming soon' tooltip - Proxy-level auth redirect for unauthenticated users on protected routes - Switch root layout to JetBrains Mono, dark-only oklch theme tokens - Add shadcn/ui Card, Input, Label, Tooltip components - Update architecture and conventions docs Co-authored-by: Ona <no-reply@ona.com>
1 parent c51c400 commit c36eca6

16 files changed

Lines changed: 837 additions & 86 deletions

File tree

.agents/architecture.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,30 +140,50 @@ Auto-save: debounce 500ms on editor change → write to Supabase
140140

141141
## Component Map
142142

143-
Current state (infrastructure only — no product features yet):
144-
145143
```
146144
src/
147145
├── app/ # Next.js App Router
148-
│ ├── layout.tsx # Root layout (currently Geist fonts — will switch to JetBrains Mono)
146+
│ ├── layout.tsx # Root layout (JetBrains Mono font, TooltipProvider)
149147
│ ├── page.tsx # Landing page
150148
│ ├── manifest.ts # PWA manifest (name, icons, display mode)
151149
│ ├── global-error.tsx # Sentry error boundary
150+
│ ├── globals.css # Tailwind v4 theme — dark-only oklch tokens, --radius: 0
151+
│ ├── (auth)/ # Unauthenticated route group
152+
│ │ ├── layout.tsx # Centered card layout for auth pages
153+
│ │ ├── sign-in/page.tsx # /sign-in — email/password form
154+
│ │ └── sign-up/page.tsx # /sign-up — display name + email/password form
155+
│ ├── (app)/ # Authenticated route group
156+
│ │ ├── layout.tsx # Auth guard (redirects to /sign-in if no session), sign-out button
157+
│ │ └── [workspaceSlug]/
158+
│ │ └── page.tsx # /[workspaceSlug] — workspace home (placeholder)
152159
│ └── api/
153160
│ └── health/route.ts # Health check endpoint (DB connectivity)
161+
├── components/
162+
│ ├── auth/
163+
│ │ ├── oauth-buttons.tsx # GitHub + Google buttons (disabled, "coming soon" tooltip)
164+
│ │ └── sign-out-button.tsx # Sign-out button (clears session, redirects to /sign-in)
165+
│ └── ui/ # shadcn/ui components (base-nova style, base-ui primitives)
166+
│ ├── button.tsx
167+
│ ├── card.tsx
168+
│ ├── input.tsx
169+
│ ├── label.tsx
170+
│ └── tooltip.tsx
154171
├── lib/
172+
│ ├── utils.ts # cn() utility (clsx + tailwind-merge)
173+
│ ├── types.ts # Database entity types
155174
│ └── supabase/
156175
│ ├── client.ts # Browser client (createBrowserClient)
157176
│ ├── server.ts # Server component client (createServerClient + cookies)
158-
│ └── proxy.ts # Session refresh logic (updateSession)
177+
│ └── proxy.ts # Session refresh + auth redirect logic (updateSession)
159178
├── proxy.ts # Root proxy — calls updateSession, skips static/health routes
160179
└── instrumentation.ts # Sentry server/edge init (register + onRequestError)
161180
162181
Root config files:
163182
├── instrumentation-client.ts # Sentry client init (replay, route transitions)
164183
├── sentry.server.config.ts # Sentry server SDK config
165184
├── sentry.edge.config.ts # Sentry edge SDK config
166-
└── sentry.client.config.ts # Sentry client SDK config
185+
├── sentry.client.config.ts # Sentry client SDK config
186+
└── components.json # shadcn/ui config (base-nova style, Tailwind v4)
167187
```
168188

169189
Planned structure (added as features are built):

.agents/conventions.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,71 @@ Rules:
284284
- No `as` casts unless unavoidable (add a comment explaining why)
285285
- Prefer interfaces for object shapes, types for unions/intersections
286286

287+
## shadcn/ui (base-nova style)
288+
289+
This project uses shadcn/ui v4 with the `base-nova` style, which uses `@base-ui/react`
290+
primitives instead of Radix. Key differences from older shadcn:
291+
292+
### Tooltip composition
293+
294+
base-ui's `TooltipTrigger` does NOT support `asChild`. Use the `render` prop instead:
295+
296+
```typescript
297+
// ✅ Correct — base-ui render prop
298+
<TooltipTrigger
299+
render={<Button variant="outline" disabled />}
300+
>
301+
Button label
302+
</TooltipTrigger>
303+
304+
// ❌ Wrong — asChild does not exist on base-ui primitives
305+
<TooltipTrigger asChild>
306+
<Button>...</Button>
307+
</TooltipTrigger>
308+
```
309+
310+
### Button primitives
311+
312+
Buttons use `@base-ui/react/button` internally. The `Button` component accepts
313+
`ButtonPrimitive.Props & VariantProps<typeof buttonVariants>`.
314+
315+
## Auth Flow
316+
317+
### Route protection (two layers)
318+
319+
1. **Proxy layer** (`src/lib/supabase/proxy.ts`): optimistic redirect — unauthenticated
320+
users on non-public routes get redirected to `/sign-in`. Public routes: `/`, `/sign-in`,
321+
`/sign-up`, `/invite/*`.
322+
2. **Layout layer** (`src/app/(app)/layout.tsx`): authoritative check — server component
323+
calls `supabase.auth.getUser()` and redirects if no user. This is the security boundary.
324+
325+
### Post-auth redirect
326+
327+
After sign-in or sign-up, the client fetches the user's workspace membership to get
328+
the workspace slug, then redirects to `/{workspaceSlug}`. The query joins `members`
329+
with `workspaces` to get the slug in one call:
330+
331+
```typescript
332+
const { data: membership } = await supabase
333+
.from("members")
334+
.select("workspace_id, workspaces(slug)")
335+
.eq("user_id", user.id)
336+
.limit(1)
337+
.maybeSingle();
338+
```
339+
340+
### Sign-up data
341+
342+
Pass `display_name` in `signUp` options so the `handle_new_user` trigger can use it:
343+
344+
```typescript
345+
await supabase.auth.signUp({
346+
email,
347+
password,
348+
options: { data: { display_name: displayName } },
349+
});
350+
```
351+
287352
## This file evolves
288353

289354
When you discover a new pattern that should be replicated, or an anti-pattern that
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { notFound } from "next/navigation";
2+
import { createClient } from "@/lib/supabase/server";
3+
4+
export default async function WorkspacePage({
5+
params,
6+
}: {
7+
params: Promise<{ workspaceSlug: string }>;
8+
}) {
9+
const { workspaceSlug } = await params;
10+
const supabase = await createClient();
11+
12+
const { data: workspace } = await supabase
13+
.from("workspaces")
14+
.select("id, name, slug")
15+
.eq("slug", workspaceSlug)
16+
.maybeSingle();
17+
18+
if (!workspace) {
19+
notFound();
20+
}
21+
22+
return (
23+
<div className="flex flex-col items-center justify-center p-6">
24+
<div className="max-w-2xl space-y-4 text-center">
25+
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
26+
<p className="text-sm text-muted-foreground">
27+
Your workspace is ready. Pages and editor coming soon.
28+
</p>
29+
</div>
30+
</div>
31+
);
32+
}

src/app/(app)/layout.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { redirect } from "next/navigation";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { SignOutButton } from "@/components/auth/sign-out-button";
4+
5+
export default async function AppLayout({
6+
children,
7+
}: {
8+
children: React.ReactNode;
9+
}) {
10+
const supabase = await createClient();
11+
const {
12+
data: { user },
13+
} = await supabase.auth.getUser();
14+
15+
if (!user) {
16+
redirect("/sign-in");
17+
}
18+
19+
return (
20+
<div className="flex min-h-screen flex-col">
21+
<header className="flex items-center justify-end border-b border-white/[0.06] px-4 py-2">
22+
<SignOutButton />
23+
</header>
24+
<main className="flex-1">{children}</main>
25+
</div>
26+
);
27+
}

src/app/(auth)/layout.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function AuthLayout({
2+
children,
3+
}: {
4+
children: React.ReactNode;
5+
}) {
6+
return (
7+
<main className="flex min-h-screen items-center justify-center p-6">
8+
<div className="w-full max-w-sm">{children}</div>
9+
</main>
10+
);
11+
}

src/app/(auth)/sign-in/page.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import Link from "next/link";
6+
import { createClient } from "@/lib/supabase/client";
7+
import { Button } from "@/components/ui/button";
8+
import { Input } from "@/components/ui/input";
9+
import { Label } from "@/components/ui/label";
10+
import {
11+
Card,
12+
CardContent,
13+
CardDescription,
14+
CardHeader,
15+
CardTitle,
16+
} from "@/components/ui/card";
17+
import { OAuthButtons } from "@/components/auth/oauth-buttons";
18+
19+
export default function SignInPage() {
20+
const router = useRouter();
21+
const [email, setEmail] = useState("");
22+
const [password, setPassword] = useState("");
23+
const [error, setError] = useState<string | null>(null);
24+
const [loading, setLoading] = useState(false);
25+
26+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
27+
e.preventDefault();
28+
setError(null);
29+
setLoading(true);
30+
31+
const supabase = createClient();
32+
const { error: signInError } = await supabase.auth.signInWithPassword({
33+
email,
34+
password,
35+
});
36+
37+
if (signInError) {
38+
setError(signInError.message);
39+
setLoading(false);
40+
return;
41+
}
42+
43+
// Fetch the user's personal workspace to redirect to
44+
const {
45+
data: { user },
46+
} = await supabase.auth.getUser();
47+
if (user) {
48+
const { data: membership } = await supabase
49+
.from("members")
50+
.select("workspace_id, workspaces(slug)")
51+
.eq("user_id", user.id)
52+
.limit(1)
53+
.maybeSingle();
54+
55+
if (membership?.workspaces) {
56+
const ws = membership.workspaces as unknown as { slug: string };
57+
router.push(`/${ws.slug}`);
58+
return;
59+
}
60+
}
61+
62+
// Fallback: redirect to root which will handle routing
63+
router.push("/");
64+
}
65+
66+
return (
67+
<Card>
68+
<CardHeader>
69+
<CardTitle className="text-2xl font-bold">Sign in to Memo</CardTitle>
70+
<CardDescription>
71+
Enter your email and password to continue.
72+
</CardDescription>
73+
</CardHeader>
74+
<CardContent>
75+
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
76+
<div className="flex flex-col gap-1.5">
77+
<Label htmlFor="email">Email</Label>
78+
<Input
79+
id="email"
80+
type="email"
81+
placeholder="you@example.com"
82+
value={email}
83+
onChange={(e) => setEmail(e.target.value)}
84+
required
85+
autoComplete="email"
86+
autoFocus
87+
/>
88+
</div>
89+
<div className="flex flex-col gap-1.5">
90+
<Label htmlFor="password">Password</Label>
91+
<Input
92+
id="password"
93+
type="password"
94+
placeholder="••••••••"
95+
value={password}
96+
onChange={(e) => setPassword(e.target.value)}
97+
required
98+
autoComplete="current-password"
99+
minLength={6}
100+
/>
101+
</div>
102+
{error && <p className="text-xs text-destructive">{error}</p>}
103+
<Button type="submit" disabled={loading} className="mt-1">
104+
{loading ? "Signing in…" : "Sign in"}
105+
</Button>
106+
</form>
107+
<div className="relative my-4">
108+
<div className="absolute inset-0 flex items-center">
109+
<span className="w-full border-t border-white/[0.06]" />
110+
</div>
111+
<div className="relative flex justify-center text-xs">
112+
<span className="bg-card px-2 text-muted-foreground">or</span>
113+
</div>
114+
</div>
115+
<OAuthButtons />
116+
<p className="mt-4 text-center text-xs text-muted-foreground">
117+
Don&apos;t have an account?{" "}
118+
<Link href="/sign-up" className="text-accent underline-offset-4 hover:underline">
119+
Sign up
120+
</Link>
121+
</p>
122+
</CardContent>
123+
</Card>
124+
);
125+
}

0 commit comments

Comments
 (0)