11.1 KB400 lines
Blame
1"use client";
2
3import { useEffect, useState } from "react";
4import { useParams, useRouter } from "next/navigation";
5import { repos, instances, orgs as orgsApi, type Repo } from "@/lib/api";
6import { useAuth } from "@/lib/auth";
7import { Button } from "@/app/components/ui/button";
8import { Input } from "@/app/components/ui/input";
9import { Dialog } from "@/app/components/ui/dialog";
10
11function SettingRow({
12 label,
13 description,
14 children,
15}: {
16 label: string;
17 description: string;
18 children: React.ReactNode;
19}) {
20 return (
21 <div className="flex items-center justify-between py-3">
22 <div>
23 <p className="text-sm" style={{ color: "var(--text-primary)" }}>
24 {label}
25 </p>
26 <p className="text-xs" style={{ color: "var(--text-muted)" }}>
27 {description}
28 </p>
29 </div>
30 {children}
31 </div>
32 );
33}
34
35function Toggle({
36 checked,
37 onChange,
38 disabled,
39}: {
40 checked: boolean;
41 onChange: (checked: boolean) => void;
42 disabled?: boolean;
43}) {
44 return (
45 <button
46 type="button"
47 role="switch"
48 aria-checked={checked}
49 disabled={disabled}
50 onClick={() => onChange(!checked)}
51 style={{
52 width: "2.5rem",
53 height: "1.375rem",
54 borderRadius: "0.75rem",
55 border: "none",
56 padding: "2px",
57 cursor: disabled ? "not-allowed" : "pointer",
58 opacity: disabled ? 0.5 : 1,
59 background: checked ? "var(--accent)" : "var(--bg-tertiary, #555)",
60 transition: "background 0.15s",
61 flexShrink: 0,
62 }}
63 >
64 <div
65 style={{
66 width: "1rem",
67 height: "1rem",
68 borderRadius: "50%",
69 background: "white",
70 transition: "transform 0.15s",
71 transform: checked ? "translateX(1.125rem)" : "translateX(0)",
72 }}
73 />
74 </button>
75 );
76}
77
78export default function SettingsPage() {
79 const { owner, repo } = useParams<{ owner: string; repo: string }>();
80 const router = useRouter();
81 const { user, loading: authLoading } = useAuth();
82
83 const [repoData, setRepoData] = useState<Repo | null>(null);
84 const [loading, setLoading] = useState(true);
85 const [authorized, setAuthorized] = useState(false);
86 const [saving, setSaving] = useState(false);
87
88 const [domainInput, setDomainInput] = useState("");
89 const [serverIp, setServerIp] = useState("");
90
91 const [showDelete, setShowDelete] = useState(false);
92 const [confirmName, setConfirmName] = useState("");
93 const [deleting, setDeleting] = useState(false);
94 const [error, setError] = useState("");
95
96 // Redirect if not logged in
97 useEffect(() => {
98 if (!authLoading && !user) {
99 router.push(`/${owner}/${repo}`);
100 }
101 }, [authLoading, user, router, owner, repo]);
102
103 useEffect(() => {
104 if (!user) return;
105
106 repos.get(owner, repo).then(({ repo: r }) => {
107 setRepoData(r);
108 setDomainInput(r.pages_domain ?? "");
109
110 // Check ownership
111 if (r.owner_type === "user") {
112 if (r.owner_name === user.username) {
113 setAuthorized(true);
114 } else {
115 router.push(`/${owner}/${repo}`);
116 }
117 } else {
118 // Org repo — check membership
119 orgsApi
120 .get(owner)
121 .then(({ members }) => {
122 if (members.some((m) => m.username === user.username)) {
123 setAuthorized(true);
124 } else {
125 router.push(`/${owner}/${repo}`);
126 }
127 })
128 .catch(() => router.push(`/${owner}/${repo}`));
129 }
130
131 setLoading(false);
132 }).catch(() => setLoading(false));
133
134 instances.list()
135 .then(({ instances: list }) => {
136 const active = list.find((i) => i.status === "active");
137 if (active?.ip) setServerIp(active.ip);
138 })
139 .catch(() => {});
140 }, [owner, repo, user, router]);
141
142 async function updateSetting(data: { is_private?: boolean; require_diffs?: boolean; pages_enabled?: boolean; pages_domain?: string | null }) {
143 setSaving(true);
144 try {
145 const { repo: updated } = await repos.update(owner, repo, data);
146 setRepoData(updated);
147 } catch (err: any) {
148 setError(err.message ?? "Failed to update setting");
149 } finally {
150 setSaving(false);
151 }
152 }
153
154 async function handleDelete() {
155 setError("");
156 setDeleting(true);
157 try {
158 await repos.delete(owner, repo);
159 router.push("/");
160 } catch (err: any) {
161 setError(err.message ?? "Failed to delete repository");
162 setDeleting(false);
163 }
164 }
165
166 if (authLoading || loading || !authorized) {
167 return (
168 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
169 Loading...
170 </p>
171 );
172 }
173
174 return (
175 <>
176 <h2 className="text-base mb-6" style={{ color: "var(--text-primary)" }}>
177 Settings
178 </h2>
179
180 {/* General settings */}
181 <div
182 style={{
183 border: "1px solid var(--border)",
184 padding: "1rem",
185 marginBottom: "1.5rem",
186 }}
187 >
188 <h3
189 className="text-sm mb-2"
190 style={{ color: "var(--text-primary)" }}
191 >
192 General
193 </h3>
194
195 <SettingRow
196 label="Private repository"
197 description="Only visible to you and organization members."
198 >
199 <Toggle
200 checked={!!repoData?.is_private}
201 onChange={(checked) => updateSetting({ is_private: checked })}
202 disabled={saving}
203 />
204 </SettingRow>
205
206 <SettingRow
207 label="Require diff reviews"
208 description="Pushes automatically create diffs for code review."
209 >
210 <Toggle
211 checked={!!repoData?.require_diffs}
212 onChange={(checked) => updateSetting({ require_diffs: checked })}
213 disabled={saving}
214 />
215 </SettingRow>
216 </div>
217
218 {/* Pages settings */}
219 <div
220 style={{
221 border: "1px solid var(--border)",
222 padding: "1rem",
223 marginBottom: "1.5rem",
224 }}
225 >
226 <h3
227 className="text-sm mb-2"
228 style={{ color: "var(--text-primary)" }}
229 >
230 Pages
231 </h3>
232
233 <SettingRow
234 label="Enable Grove Pages"
235 description="Serve this repository as a static website."
236 >
237 <Toggle
238 checked={!!repoData?.pages_enabled}
239 onChange={(checked) => updateSetting({ pages_enabled: checked })}
240 disabled={saving}
241 />
242 </SettingRow>
243
244 {!!repoData?.pages_enabled && (
245 <div className="py-3">
246 {(() => {
247 const defaultUrl = `https://pages.grove.host/${repoData?.owner_name}/${repoData?.name}`;
248 const customUrl = repoData?.pages_domain ? `https://${repoData.pages_domain}` : null;
249 return (
250 <div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
251 <p>
252 Serving at{" "}
253 <a
254 href={defaultUrl}
255 target="_blank"
256 rel="noopener noreferrer"
257 style={{ color: "var(--accent)" }}
258 >
259 {defaultUrl}
260 </a>
261 </p>
262 {customUrl && (
263 <p className="mt-1">
264 Custom domain:{" "}
265 <a
266 href={customUrl}
267 target="_blank"
268 rel="noopener noreferrer"
269 style={{ color: "var(--accent)" }}
270 >
271 {customUrl}
272 </a>
273 </p>
274 )}
275 </div>
276 );
277 })()}
278 <p className="text-sm mb-1" style={{ color: "var(--text-primary)" }}>
279 Custom domain (optional)
280 </p>
281 <p className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
282 Point your domain&apos;s A record to{" "}
283 {serverIp ? (
284 <code style={{ color: "var(--text-primary)" }}>{serverIp}</code>
285 ) : (
286 "this server\u2019s IP"
287 )}
288 , then enter it here.
289 </p>
290 <div className="flex gap-2 items-center">
291 <Input
292 value={domainInput}
293 onChange={(e) => setDomainInput(e.target.value)}
294 placeholder="example.com"
295 style={{ flex: 1 }}
296 />
297 <Button
298 onClick={() => updateSetting({ pages_domain: domainInput || null })}
299 disabled={saving || domainInput === (repoData?.pages_domain ?? "")}
300 >
301 Save
302 </Button>
303 </div>
304 </div>
305 )}
306 </div>
307
308 {/* Danger zone */}
309 <div
310 style={{
311 border: "1px solid var(--status-closed-border)",
312 padding: "1rem",
313 }}
314 >
315 <h3
316 className="text-sm mb-4"
317 style={{ color: "var(--status-closed-text)" }}
318 >
319 Danger zone
320 </h3>
321
322 <div className="flex items-center justify-between">
323 <div>
324 <p className="text-sm" style={{ color: "var(--text-primary)" }}>
325 Delete this repository
326 </p>
327 <p className="text-xs" style={{ color: "var(--text-muted)" }}>
328 Once deleted, this cannot be undone.
329 </p>
330 </div>
331 <Button variant="danger" onClick={() => setShowDelete(true)}>
332 Delete
333 </Button>
334 </div>
335 </div>
336
337 <Dialog
338 open={showDelete}
339 onClose={() => {
340 if (!deleting) {
341 setShowDelete(false);
342 setConfirmName("");
343 setError("");
344 }
345 }}
346 title="Delete repository"
347 actions={
348 <>
349 <Button
350 variant="secondary"
351 onClick={() => {
352 setShowDelete(false);
353 setConfirmName("");
354 setError("");
355 }}
356 disabled={deleting}
357 >
358 Cancel
359 </Button>
360 <Button
361 variant="danger"
362 onClick={handleDelete}
363 disabled={confirmName !== repo || deleting}
364 loading={deleting}
365 loadingText="Deleting..."
366 >
367 Delete this repository
368 </Button>
369 </>
370 }
371 >
372 <p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
373 This will permanently delete{" "}
374 <strong style={{ color: "var(--text-primary)" }}>
375 {owner}/{repo}
376 </strong>{" "}
377 and all associated data including diffs, pipelines, and secrets.
378 </p>
379
380 <Input
381 label={`Type "${repo}" to confirm`}
382 value={confirmName}
383 onChange={(e) => setConfirmName(e.target.value)}
384 placeholder={repo}
385 autoFocus
386 />
387
388 {error && (
389 <p
390 className="text-xs mt-3"
391 style={{ color: "var(--error-text)" }}
392 >
393 {error}
394 </p>
395 )}
396 </Dialog>
397 </>
398 );
399}
400