web/app/settings/page.tsxblame
View source
27902ea1"use client";
27902ea2
27902ea3import { useEffect, useState } from "react";
27902ea4import { useRouter } from "next/navigation";
27902ea5import { useAuth } from "@/lib/auth";
27902ea6import { useTheme } from "@/lib/theme";
27902ea7import { auth as authApi, type ApiToken } from "@/lib/api";
27902ea8import { Skeleton } from "@/app/components/skeleton";
27902ea9
27902ea10export default function SettingsPage() {
27902ea11 const { user, loading: authLoading } = useAuth();
27902ea12 const { theme, toggle: toggleTheme } = useTheme();
27902ea13 const router = useRouter();
27902ea14
27902ea15 const [tokens, setTokens] = useState<ApiToken[]>([]);
27902ea16 const [tokensLoaded, setTokensLoaded] = useState(false);
27902ea17
27902ea18 // New token form
27902ea19 const [showNewToken, setShowNewToken] = useState(false);
27902ea20 const [tokenName, setTokenName] = useState("");
27902ea21 const [tokenExpiry, setTokenExpiry] = useState<"30d" | "90d" | "1y">("90d");
27902ea22 const [createdToken, setCreatedToken] = useState<string | null>(null);
27902ea23 const [tokenCreating, setTokenCreating] = useState(false);
27902ea24 const [tokenError, setTokenError] = useState("");
27902ea25
27902ea26 useEffect(() => {
27902ea27 document.title = "Settings";
27902ea28 }, []);
27902ea29
27902ea30 useEffect(() => {
27902ea31 if (!authLoading && !user) {
27902ea32 router.push(`/login?redirect=${encodeURIComponent("/settings")}`);
27902ea33 }
27902ea34 }, [authLoading, user, router]);
27902ea35
27902ea36 useEffect(() => {
27902ea37 if (!user) return;
27902ea38 authApi.listTokens().then(({ tokens }) => {
27902ea39 setTokens(tokens);
27902ea40 setTokensLoaded(true);
27902ea41 }).catch(() => setTokensLoaded(true));
27902ea42 }, [user]);
27902ea43
27902ea44 async function handleCreateToken(e: React.FormEvent) {
27902ea45 e.preventDefault();
27902ea46 setTokenError("");
27902ea47 setTokenCreating(true);
27902ea48 try {
27902ea49 const { token, api_token } = await authApi.createToken({
27902ea50 name: tokenName,
27902ea51 expires_in: tokenExpiry,
27902ea52 });
27902ea53 setCreatedToken(token);
27902ea54 setTokens((prev) => [api_token, ...prev]);
27902ea55 setTokenName("");
27902ea56 setShowNewToken(false);
27902ea57 } catch (err: unknown) {
27902ea58 setTokenError(err instanceof Error ? err.message : "Failed to create token");
27902ea59 } finally {
27902ea60 setTokenCreating(false);
27902ea61 }
27902ea62 }
27902ea63
27902ea64 async function handleDeleteToken(id: number) {
27902ea65 try {
27902ea66 await authApi.deleteToken(id);
27902ea67 setTokens((prev) => prev.filter((t) => t.id !== id));
27902ea68 } catch {}
27902ea69 }
27902ea70
27902ea71 if (authLoading || !user) return null;
27902ea72
27902ea73 return (
27902ea74 <div style={{ maxWidth: 640, margin: "0 auto", padding: "1.5rem 1rem 3rem" }}>
27902ea75 <h1 className="text-lg font-medium" style={{ color: "var(--text-primary)", margin: "0 0 1.5rem" }}>
27902ea76 Settings
27902ea77 </h1>
27902ea78
27902ea79 {/* Appearance */}
27902ea80 <Section title="Appearance">
27902ea81 <div className="flex items-center justify-between py-2.5 px-1" style={{ borderBottom: "1px solid var(--divide)" }}>
27902ea82 <div>
27902ea83 <div className="text-sm" style={{ color: "var(--text-primary)" }}>Theme</div>
27902ea84 <div className="text-xs mt-0.5" style={{ color: "var(--text-faint)" }}>{theme} mode</div>
27902ea85 </div>
27902ea86 <button
27902ea87 onClick={toggleTheme}
27902ea88 className="btn-reset text-xs"
27902ea89 style={{ color: "var(--accent)", font: "inherit", fontSize: "0.75rem" }}
27902ea90 >
27902ea91 Switch to {theme === "light" ? "dark" : "light"}
27902ea92 </button>
27902ea93 </div>
27902ea94 </Section>
27902ea95
27902ea96 {/* API Tokens */}
27902ea97 <Section
27902ea98 title="API Tokens"
27902ea99 action={
27902ea100 <button
27902ea101 onClick={() => { setShowNewToken((v) => !v); setCreatedToken(null); }}
27902ea102 className="btn-reset text-xs"
27902ea103 style={{ color: "var(--text-muted)", font: "inherit", fontSize: "0.75rem" }}
27902ea104 >
27902ea105 {showNewToken ? "Cancel" : "New token"}
27902ea106 </button>
27902ea107 }
27902ea108 >
27902ea109 {createdToken && (
27902ea110 <div
27902ea111 className="px-3 py-2 text-xs font-mono"
27902ea112 style={{
27902ea113 backgroundColor: "var(--status-open-bg)",
27902ea114 border: "1px solid var(--status-open-border)",
27902ea115 color: "var(--status-open-text)",
27902ea116 wordBreak: "break-all",
27902ea117 borderBottom: "1px solid var(--divide)",
27902ea118 }}
27902ea119 >
27902ea120 <div className="text-xs" style={{ fontFamily: "inherit", marginBottom: 2 }}>
27902ea121 Copy this token now — it won't be shown again:
27902ea122 </div>
27902ea123 {createdToken}
27902ea124 </div>
27902ea125 )}
27902ea126
27902ea127 {showNewToken && (
27902ea128 <form onSubmit={handleCreateToken} className="px-3 py-2.5 flex flex-col gap-2" style={{ borderBottom: "1px solid var(--divide)" }}>
27902ea129 <input
27902ea130 value={tokenName}
27902ea131 onChange={(e) => setTokenName(e.target.value)}
27902ea132 placeholder="Token name (e.g. laptop)"
27902ea133 required
27902ea134 className="text-sm px-2.5 py-1.5"
27902ea135 style={{
27902ea136 backgroundColor: "var(--bg-input)",
27902ea137 border: "1px solid var(--border-subtle)",
27902ea138 color: "var(--text-primary)",
27902ea139 font: "inherit",
27902ea140 fontSize: "0.8125rem",
27902ea141 }}
27902ea142 />
27902ea143 <div className="flex items-center gap-2">
27902ea144 {(["30d", "90d", "1y"] as const).map((exp) => (
27902ea145 <button
27902ea146 key={exp}
27902ea147 type="button"
27902ea148 onClick={() => setTokenExpiry(exp)}
27902ea149 className="text-xs px-2 py-1"
27902ea150 style={{
27902ea151 backgroundColor: tokenExpiry === exp ? "var(--accent)" : "var(--bg-inset)",
27902ea152 color: tokenExpiry === exp ? "var(--accent-text)" : "var(--text-muted)",
27902ea153 border: `1px solid ${tokenExpiry === exp ? "var(--accent)" : "var(--border-subtle)"}`,
27902ea154 cursor: "pointer",
27902ea155 font: "inherit",
27902ea156 fontSize: "0.6875rem",
27902ea157 }}
27902ea158 >
27902ea159 {exp === "30d" ? "30 days" : exp === "90d" ? "90 days" : "1 year"}
27902ea160 </button>
27902ea161 ))}
27902ea162 </div>
27902ea163 {tokenError && <div className="text-xs" style={{ color: "var(--status-closed-text)" }}>{tokenError}</div>}
27902ea164 <button
27902ea165 type="submit"
27902ea166 disabled={tokenCreating}
27902ea167 className="text-xs self-start px-3 py-1.5"
27902ea168 style={{
27902ea169 backgroundColor: "var(--accent)",
27902ea170 color: "var(--accent-text)",
27902ea171 border: "none",
27902ea172 cursor: tokenCreating ? "wait" : "pointer",
27902ea173 font: "inherit",
27902ea174 fontSize: "0.6875rem",
27902ea175 opacity: tokenCreating ? 0.6 : 1,
27902ea176 }}
27902ea177 >
27902ea178 {tokenCreating ? "Creating..." : "Create token"}
27902ea179 </button>
27902ea180 </form>
27902ea181 )}
27902ea182
27902ea183 {!tokensLoaded ? (
27902ea184 <div className="p-3 space-y-1">
27902ea185 <Skeleton width="100%" height="2rem" />
27902ea186 <Skeleton width="100%" height="2rem" />
27902ea187 </div>
27902ea188 ) : tokens.length === 0 ? (
27902ea189 <div className="py-4 text-center text-xs" style={{ color: "var(--text-faint)" }}>
27902ea190 No API tokens.
27902ea191 </div>
27902ea192 ) : (
27902ea193 tokens.map((t) => (
27902ea194 <div
27902ea195 key={t.id}
27902ea196 className="flex items-center justify-between px-3 py-2"
27902ea197 style={{ borderBottom: "1px solid var(--divide)" }}
27902ea198 >
27902ea199 <div>
27902ea200 <div className="text-sm" style={{ color: "var(--text-primary)" }}>
27902ea201 {t.name}
27902ea202 <span className="font-mono text-xs ml-1.5" style={{ color: "var(--text-faint)" }}>#{t.id}</span>
27902ea203 </div>
27902ea204 <div className="text-xs" style={{ color: "var(--text-faint)" }}>
27902ea205 Expires {new Date(t.expires_at).toLocaleDateString()}
27902ea206 {t.last_used_at && <> · last used {new Date(t.last_used_at).toLocaleDateString()}</>}
27902ea207 </div>
27902ea208 </div>
27902ea209 <button
27902ea210 onClick={() => handleDeleteToken(t.id)}
27902ea211 className="btn-reset text-xs"
27902ea212 style={{ color: "var(--status-closed-text)", font: "inherit", fontSize: "0.6875rem" }}
27902ea213 >
27902ea214 Revoke
27902ea215 </button>
27902ea216 </div>
27902ea217 ))
27902ea218 )}
27902ea219 </Section>
27902ea220 </div>
27902ea221 );
27902ea222}
27902ea223
27902ea224function Section({
27902ea225 title,
27902ea226 action,
27902ea227 children,
27902ea228}: {
27902ea229 title: string;
27902ea230 action?: React.ReactNode;
27902ea231 children: React.ReactNode;
27902ea232}) {
27902ea233 return (
27902ea234 <div style={{ marginBottom: "1.5rem" }}>
27902ea235 <div
27902ea236 className="flex items-center justify-between pb-2"
27902ea237 style={{ borderBottom: "1px solid var(--divide)" }}
27902ea238 >
27902ea239 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{title}</span>
27902ea240 {action}
27902ea241 </div>
27902ea242 {children}
27902ea243 </div>
27902ea244 );
27902ea245}
27902ea246