8.4 KB247 lines
Blame
1"use client";
2
3import { useEffect, useState } from "react";
4import { useRouter } from "next/navigation";
5import { useAuth } from "@/lib/auth";
6import { useTheme } from "@/lib/theme";
7import { auth as authApi, type ApiToken } from "@/lib/api";
8import { Skeleton } from "@/app/components/skeleton";
9
10export default function SettingsPage() {
11 const { user, loading: authLoading } = useAuth();
12 const { theme, toggle: toggleTheme } = useTheme();
13 const router = useRouter();
14
15 const [tokens, setTokens] = useState<ApiToken[]>([]);
16 const [tokensLoaded, setTokensLoaded] = useState(false);
17
18 // New token form
19 const [showNewToken, setShowNewToken] = useState(false);
20 const [tokenName, setTokenName] = useState("");
21 const [tokenExpiry, setTokenExpiry] = useState<"30d" | "90d" | "1y">("90d");
22 const [createdToken, setCreatedToken] = useState<string | null>(null);
23 const [tokenCreating, setTokenCreating] = useState(false);
24 const [tokenError, setTokenError] = useState("");
25
26 useEffect(() => {
27 document.title = "Settings";
28 }, []);
29
30 useEffect(() => {
31 if (!authLoading && !user) {
32 router.push(`/login?redirect=${encodeURIComponent("/settings")}`);
33 }
34 }, [authLoading, user, router]);
35
36 useEffect(() => {
37 if (!user) return;
38 authApi.listTokens().then(({ tokens }) => {
39 setTokens(tokens);
40 setTokensLoaded(true);
41 }).catch(() => setTokensLoaded(true));
42 }, [user]);
43
44 async function handleCreateToken(e: React.FormEvent) {
45 e.preventDefault();
46 setTokenError("");
47 setTokenCreating(true);
48 try {
49 const { token, api_token } = await authApi.createToken({
50 name: tokenName,
51 expires_in: tokenExpiry,
52 });
53 setCreatedToken(token);
54 setTokens((prev) => [api_token, ...prev]);
55 setTokenName("");
56 setShowNewToken(false);
57 } catch (err: unknown) {
58 setTokenError(err instanceof Error ? err.message : "Failed to create token");
59 } finally {
60 setTokenCreating(false);
61 }
62 }
63
64 async function handleDeleteToken(id: number) {
65 try {
66 await authApi.deleteToken(id);
67 setTokens((prev) => prev.filter((t) => t.id !== id));
68 } catch {}
69 }
70
71 if (authLoading || !user) return null;
72
73 return (
74 <div style={{ maxWidth: 640, margin: "0 auto", padding: "1.5rem 1rem 3rem" }}>
75 <h1 className="text-lg font-medium" style={{ color: "var(--text-primary)", margin: "0 0 1.5rem" }}>
76 Settings
77 </h1>
78
79 {/* Appearance */}
80 <Section title="Appearance">
81 <div className="flex items-center justify-between py-2.5 px-1" style={{ borderBottom: "1px solid var(--divide)" }}>
82 <div>
83 <div className="text-sm" style={{ color: "var(--text-primary)" }}>Theme</div>
84 <div className="text-xs mt-0.5" style={{ color: "var(--text-faint)" }}>{theme} mode</div>
85 </div>
86 <button
87 onClick={toggleTheme}
88 className="btn-reset text-xs"
89 style={{ color: "var(--accent)", font: "inherit", fontSize: "0.75rem" }}
90 >
91 Switch to {theme === "light" ? "dark" : "light"}
92 </button>
93 </div>
94 </Section>
95
96 {/* API Tokens */}
97 <Section
98 title="API Tokens"
99 action={
100 <button
101 onClick={() => { setShowNewToken((v) => !v); setCreatedToken(null); }}
102 className="btn-reset text-xs"
103 style={{ color: "var(--text-muted)", font: "inherit", fontSize: "0.75rem" }}
104 >
105 {showNewToken ? "Cancel" : "New token"}
106 </button>
107 }
108 >
109 {createdToken && (
110 <div
111 className="px-3 py-2 text-xs font-mono"
112 style={{
113 backgroundColor: "var(--status-open-bg)",
114 border: "1px solid var(--status-open-border)",
115 color: "var(--status-open-text)",
116 wordBreak: "break-all",
117 borderBottom: "1px solid var(--divide)",
118 }}
119 >
120 <div className="text-xs" style={{ fontFamily: "inherit", marginBottom: 2 }}>
121 Copy this token now — it won't be shown again:
122 </div>
123 {createdToken}
124 </div>
125 )}
126
127 {showNewToken && (
128 <form onSubmit={handleCreateToken} className="px-3 py-2.5 flex flex-col gap-2" style={{ borderBottom: "1px solid var(--divide)" }}>
129 <input
130 value={tokenName}
131 onChange={(e) => setTokenName(e.target.value)}
132 placeholder="Token name (e.g. laptop)"
133 required
134 className="text-sm px-2.5 py-1.5"
135 style={{
136 backgroundColor: "var(--bg-input)",
137 border: "1px solid var(--border-subtle)",
138 color: "var(--text-primary)",
139 font: "inherit",
140 fontSize: "0.8125rem",
141 }}
142 />
143 <div className="flex items-center gap-2">
144 {(["30d", "90d", "1y"] as const).map((exp) => (
145 <button
146 key={exp}
147 type="button"
148 onClick={() => setTokenExpiry(exp)}
149 className="text-xs px-2 py-1"
150 style={{
151 backgroundColor: tokenExpiry === exp ? "var(--accent)" : "var(--bg-inset)",
152 color: tokenExpiry === exp ? "var(--accent-text)" : "var(--text-muted)",
153 border: `1px solid ${tokenExpiry === exp ? "var(--accent)" : "var(--border-subtle)"}`,
154 cursor: "pointer",
155 font: "inherit",
156 fontSize: "0.6875rem",
157 }}
158 >
159 {exp === "30d" ? "30 days" : exp === "90d" ? "90 days" : "1 year"}
160 </button>
161 ))}
162 </div>
163 {tokenError && <div className="text-xs" style={{ color: "var(--status-closed-text)" }}>{tokenError}</div>}
164 <button
165 type="submit"
166 disabled={tokenCreating}
167 className="text-xs self-start px-3 py-1.5"
168 style={{
169 backgroundColor: "var(--accent)",
170 color: "var(--accent-text)",
171 border: "none",
172 cursor: tokenCreating ? "wait" : "pointer",
173 font: "inherit",
174 fontSize: "0.6875rem",
175 opacity: tokenCreating ? 0.6 : 1,
176 }}
177 >
178 {tokenCreating ? "Creating..." : "Create token"}
179 </button>
180 </form>
181 )}
182
183 {!tokensLoaded ? (
184 <div className="p-3 space-y-1">
185 <Skeleton width="100%" height="2rem" />
186 <Skeleton width="100%" height="2rem" />
187 </div>
188 ) : tokens.length === 0 ? (
189 <div className="py-4 text-center text-xs" style={{ color: "var(--text-faint)" }}>
190 No API tokens.
191 </div>
192 ) : (
193 tokens.map((t) => (
194 <div
195 key={t.id}
196 className="flex items-center justify-between px-3 py-2"
197 style={{ borderBottom: "1px solid var(--divide)" }}
198 >
199 <div>
200 <div className="text-sm" style={{ color: "var(--text-primary)" }}>
201 {t.name}
202 <span className="font-mono text-xs ml-1.5" style={{ color: "var(--text-faint)" }}>#{t.id}</span>
203 </div>
204 <div className="text-xs" style={{ color: "var(--text-faint)" }}>
205 Expires {new Date(t.expires_at).toLocaleDateString()}
206 {t.last_used_at && <> · last used {new Date(t.last_used_at).toLocaleDateString()}</>}
207 </div>
208 </div>
209 <button
210 onClick={() => handleDeleteToken(t.id)}
211 className="btn-reset text-xs"
212 style={{ color: "var(--status-closed-text)", font: "inherit", fontSize: "0.6875rem" }}
213 >
214 Revoke
215 </button>
216 </div>
217 ))
218 )}
219 </Section>
220 </div>
221 );
222}
223
224function Section({
225 title,
226 action,
227 children,
228}: {
229 title: string;
230 action?: React.ReactNode;
231 children: React.ReactNode;
232}) {
233 return (
234 <div style={{ marginBottom: "1.5rem" }}>
235 <div
236 className="flex items-center justify-between pb-2"
237 style={{ borderBottom: "1px solid var(--divide)" }}
238 >
239 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{title}</span>
240 {action}
241 </div>
242 {children}
243 </div>
244 );
245}
246
247