Skip to content

Commit 1426b1e

Browse files
Kikolatorclaude
andauthored
feat: add dev email-password auth to web and admin apps (#83)
## Summary - Add development-only email/password sign-in and sign-up to both `apps/web` and `apps/admin` - Gated behind `NEXT_PUBLIC_APP_ENV=development` — hidden in production - Admin flow checks `platform_admins` table and tracks `last_login_at`; web flow resolves space and sets JWT claims ## Test plan - [ ] Set `NEXT_PUBLIC_APP_ENV=development` in `.env.local` for both apps - [ ] Verify password section appears on login pages - [ ] Test sign-in with existing dev user credentials - [ ] Test sign-up creates account and redirects correctly - [ ] Remove/unset `NEXT_PUBLIC_APP_ENV` and verify password section is hidden - [ ] Admin: verify non-admin user gets "Not a platform admin" error 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4af7040 commit 1426b1e

File tree

6 files changed

+494
-61
lines changed

6 files changed

+494
-61
lines changed

apps/admin/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ NEXT_PUBLIC_SUPABASE_PUB_KEY=
33
SUPABASE_SECRET_KEY=
44
STRIPE_SECRET_KEY=
55
NEXT_PUBLIC_PLATFORM_DOMAIN=rogueops.app
6+
NEXT_PUBLIC_APP_ENV=development
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"use server";
2+
3+
import { createClient } from "@/lib/supabase/server";
4+
import { createAdminClient } from "@/lib/supabase/admin";
5+
6+
type DevAuthResult = {
7+
error: string | null;
8+
redirectTo?: string;
9+
message?: string;
10+
};
11+
12+
export async function signInWithDevPassword(
13+
email: string,
14+
password: string,
15+
): Promise<DevAuthResult> {
16+
if (process.env.NEXT_PUBLIC_APP_ENV !== "development") {
17+
return { error: "Dev auth is not enabled" };
18+
}
19+
20+
const supabase = await createClient();
21+
const { error, data } = await supabase.auth.signInWithPassword({
22+
email,
23+
password,
24+
});
25+
26+
if (error) {
27+
return { error: error.message };
28+
}
29+
30+
// Verify platform admin access
31+
const admin = createAdminClient();
32+
const { data: platformAdmin } = await admin
33+
.from("platform_admins")
34+
.select("user_id")
35+
.eq("user_id", data.user.id)
36+
.single();
37+
38+
if (!platformAdmin) {
39+
await supabase.auth.signOut();
40+
return { error: "Not a platform admin" };
41+
}
42+
43+
// Track last login
44+
await admin
45+
.from("shared_profiles")
46+
.update({ last_login_at: new Date().toISOString() })
47+
.eq("id", data.user.id);
48+
49+
return { error: null, redirectTo: "/" };
50+
}
51+
52+
export async function signUpWithDevPassword(
53+
email: string,
54+
password: string,
55+
): Promise<DevAuthResult> {
56+
if (process.env.NEXT_PUBLIC_APP_ENV !== "development") {
57+
return { error: "Dev auth is not enabled" };
58+
}
59+
60+
const supabase = await createClient();
61+
const { error, data } = await supabase.auth.signUp({
62+
email,
63+
password,
64+
});
65+
66+
if (error) {
67+
return { error: error.message };
68+
}
69+
70+
if (!data.user) {
71+
return { error: "Sign up failed" };
72+
}
73+
74+
if (!data.session) {
75+
return {
76+
error: null,
77+
message: "Check your email to confirm your account",
78+
};
79+
}
80+
81+
// Verify platform admin access
82+
const admin = createAdminClient();
83+
const { data: platformAdmin } = await admin
84+
.from("platform_admins")
85+
.select("user_id")
86+
.eq("user_id", data.user.id)
87+
.single();
88+
89+
if (!platformAdmin) {
90+
await supabase.auth.signOut();
91+
return { error: "Not a platform admin" };
92+
}
93+
94+
// Track last login
95+
await admin
96+
.from("shared_profiles")
97+
.update({ last_login_at: new Date().toISOString() })
98+
.eq("id", data.user.id);
99+
100+
return { error: null, redirectTo: "/" };
101+
}

apps/admin/app/login/login-form.tsx

Lines changed: 119 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22

33
import { useState } from "react";
44
import { sendMagicLink } from "./actions";
5+
import {
6+
signInWithDevPassword,
7+
signUpWithDevPassword,
8+
} from "./dev-auth-actions";
9+
10+
const isDevAuth = process.env.NEXT_PUBLIC_APP_ENV === "development";
511

612
export function LoginForm() {
713
const [email, setEmail] = useState("");
14+
const [password, setPassword] = useState("");
815
const [sent, setSent] = useState(false);
916
const [error, setError] = useState<string | null>(null);
17+
const [message, setMessage] = useState<string | null>(null);
1018
const [pending, setPending] = useState(false);
1119

1220
async function handleSubmit(e: React.FormEvent) {
@@ -25,6 +33,39 @@ export function LoginForm() {
2533
}
2634
}
2735

36+
async function handleDevSignIn() {
37+
setPending(true);
38+
setError(null);
39+
setMessage(null);
40+
41+
const result = await signInWithDevPassword(email, password);
42+
43+
if (result.error) {
44+
setError(result.error);
45+
setPending(false);
46+
} else if (result.redirectTo) {
47+
window.location.href = result.redirectTo;
48+
}
49+
}
50+
51+
async function handleDevSignUp() {
52+
setPending(true);
53+
setError(null);
54+
setMessage(null);
55+
56+
const result = await signUpWithDevPassword(email, password);
57+
58+
if (result.error) {
59+
setError(result.error);
60+
setPending(false);
61+
} else if (result.message) {
62+
setMessage(result.message);
63+
setPending(false);
64+
} else if (result.redirectTo) {
65+
window.location.href = result.redirectTo;
66+
}
67+
}
68+
2869
if (sent) {
2970
return (
3071
<div className="rounded-xl border border-green-400/20 bg-green-400/10 p-6 text-center">
@@ -40,36 +81,85 @@ export function LoginForm() {
4081
}
4182

4283
return (
43-
<form onSubmit={handleSubmit} className="space-y-4">
44-
<div>
45-
<label
46-
htmlFor="email"
47-
className="block text-sm font-medium text-foreground/80"
84+
<div className="space-y-4">
85+
<form onSubmit={handleSubmit} className="space-y-4">
86+
<div>
87+
<label
88+
htmlFor="email"
89+
className="block text-sm font-medium text-foreground/80"
90+
>
91+
Email address
92+
</label>
93+
<input
94+
id="email"
95+
name="email"
96+
type="email"
97+
autoComplete="email"
98+
required
99+
value={email}
100+
onChange={(e) => setEmail(e.target.value)}
101+
className="mt-1 block w-full rounded-xl border border-border bg-white/5 px-3 py-2.5 text-sm shadow-sm transition-all duration-200 placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
102+
placeholder="you@example.com"
103+
/>
104+
</div>
105+
{error && (
106+
<p className="text-sm text-red-400">{error}</p>
107+
)}
108+
{message && (
109+
<p className="text-sm text-green-400">{message}</p>
110+
)}
111+
<button
112+
type="submit"
113+
disabled={pending}
114+
className="w-full rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground shadow-sm transition-all duration-200 hover:bg-primary/90 hover:shadow-md disabled:opacity-50"
48115
>
49-
Email address
50-
</label>
51-
<input
52-
id="email"
53-
name="email"
54-
type="email"
55-
autoComplete="email"
56-
required
57-
value={email}
58-
onChange={(e) => setEmail(e.target.value)}
59-
className="mt-1 block w-full rounded-xl border border-border bg-white/5 px-3 py-2.5 text-sm shadow-sm transition-all duration-200 placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
60-
placeholder="you@example.com"
61-
/>
62-
</div>
63-
{error && (
64-
<p className="text-sm text-red-400">{error}</p>
116+
{pending ? "Sending..." : "Send magic link"}
117+
</button>
118+
</form>
119+
120+
{isDevAuth && (
121+
<div className="space-y-3 border-t border-dashed border-border pt-4">
122+
<p className="text-center text-xs font-medium uppercase tracking-wider text-amber-400">
123+
Dev only
124+
</p>
125+
<div>
126+
<label
127+
htmlFor="password"
128+
className="block text-sm font-medium text-foreground/80"
129+
>
130+
Password
131+
</label>
132+
<input
133+
id="password"
134+
name="password"
135+
type="password"
136+
autoComplete="current-password"
137+
value={password}
138+
onChange={(e) => setPassword(e.target.value)}
139+
className="mt-1 block w-full rounded-xl border border-border bg-white/5 px-3 py-2.5 text-sm shadow-sm transition-all duration-200 placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
140+
placeholder="Password"
141+
/>
142+
</div>
143+
<div className="flex gap-2">
144+
<button
145+
type="button"
146+
disabled={pending || !email || !password}
147+
onClick={handleDevSignIn}
148+
className="flex-1 rounded-xl border border-border bg-white/5 px-4 py-2.5 text-sm font-medium text-foreground shadow-sm transition-all duration-200 hover:bg-white/10 disabled:opacity-50"
149+
>
150+
Sign in
151+
</button>
152+
<button
153+
type="button"
154+
disabled={pending || !email || !password}
155+
onClick={handleDevSignUp}
156+
className="flex-1 rounded-xl border border-dashed border-border bg-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-white/5 disabled:opacity-50"
157+
>
158+
Sign up
159+
</button>
160+
</div>
161+
</div>
65162
)}
66-
<button
67-
type="submit"
68-
disabled={pending}
69-
className="w-full rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground shadow-sm transition-all duration-200 hover:bg-primary/90 hover:shadow-md disabled:opacity-50"
70-
>
71-
{pending ? "Sending..." : "Send magic link"}
72-
</button>
73-
</form>
163+
</div>
74164
);
75165
}

apps/web/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# App environment — controls dev-only features (e.g. password auth)
2+
# Set to "development" for local/preview, omit or set "production" for prod
3+
NEXT_PUBLIC_APP_ENV=development
4+
15
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
26
NEXT_PUBLIC_SUPABASE_PUB_KEY=your-anon-key
37
SUPABASE_SECRET_KEY=your-service-role-key

0 commit comments

Comments
 (0)