web/app/%5Bowner%5D/%5Brepo%5D/(tabs)/settings/page.tsxblame
View source
ab61b9d1"use client";
ab61b9d2
8d8e8153import { useEffect, useState } from "react";
ab61b9d4import { useParams, useRouter } from "next/navigation";
6dd74de5import { repos, instances, orgs as orgsApi, type Repo } from "@/lib/api";
6dd74de6import { useAuth } from "@/lib/auth";
ab61b9d7import { Button } from "@/app/components/ui/button";
ab61b9d8import { Input } from "@/app/components/ui/input";
ab61b9d9import { Dialog } from "@/app/components/ui/dialog";
ab61b9d10
8d8e81511function SettingRow({
8d8e81512 label,
8d8e81513 description,
8d8e81514 children,
8d8e81515}: {
8d8e81516 label: string;
8d8e81517 description: string;
8d8e81518 children: React.ReactNode;
8d8e81519}) {
8d8e81520 return (
8d8e81521 <div className="flex items-center justify-between py-3">
8d8e81522 <div>
8d8e81523 <p className="text-sm" style={{ color: "var(--text-primary)" }}>
8d8e81524 {label}
8d8e81525 </p>
8d8e81526 <p className="text-xs" style={{ color: "var(--text-muted)" }}>
8d8e81527 {description}
8d8e81528 </p>
8d8e81529 </div>
8d8e81530 {children}
8d8e81531 </div>
8d8e81532 );
8d8e81533}
8d8e81534
8d8e81535function Toggle({
8d8e81536 checked,
8d8e81537 onChange,
8d8e81538 disabled,
8d8e81539}: {
8d8e81540 checked: boolean;
8d8e81541 onChange: (checked: boolean) => void;
8d8e81542 disabled?: boolean;
8d8e81543}) {
8d8e81544 return (
8d8e81545 <button
8d8e81546 type="button"
8d8e81547 role="switch"
8d8e81548 aria-checked={checked}
8d8e81549 disabled={disabled}
8d8e81550 onClick={() => onChange(!checked)}
8d8e81551 style={{
8d8e81552 width: "2.5rem",
8d8e81553 height: "1.375rem",
8d8e81554 borderRadius: "0.75rem",
8d8e81555 border: "none",
8d8e81556 padding: "2px",
8d8e81557 cursor: disabled ? "not-allowed" : "pointer",
8d8e81558 opacity: disabled ? 0.5 : 1,
8d8e81559 background: checked ? "var(--accent)" : "var(--bg-tertiary, #555)",
8d8e81560 transition: "background 0.15s",
8d8e81561 flexShrink: 0,
8d8e81562 }}
8d8e81563 >
8d8e81564 <div
8d8e81565 style={{
8d8e81566 width: "1rem",
8d8e81567 height: "1rem",
8d8e81568 borderRadius: "50%",
8d8e81569 background: "white",
8d8e81570 transition: "transform 0.15s",
8d8e81571 transform: checked ? "translateX(1.125rem)" : "translateX(0)",
8d8e81572 }}
8d8e81573 />
8d8e81574 </button>
8d8e81575 );
8d8e81576}
8d8e81577
ab61b9d78export default function SettingsPage() {
ab61b9d79 const { owner, repo } = useParams<{ owner: string; repo: string }>();
ab61b9d80 const router = useRouter();
6dd74de81 const { user, loading: authLoading } = useAuth();
ab61b9d82
8d8e81583 const [repoData, setRepoData] = useState<Repo | null>(null);
8d8e81584 const [loading, setLoading] = useState(true);
6dd74de85 const [authorized, setAuthorized] = useState(false);
8d8e81586 const [saving, setSaving] = useState(false);
8d8e81587
e5b523e88 const [domainInput, setDomainInput] = useState("");
85f749789 const [serverIp, setServerIp] = useState("");
e5b523e90
ab61b9d91 const [showDelete, setShowDelete] = useState(false);
ab61b9d92 const [confirmName, setConfirmName] = useState("");
ab61b9d93 const [deleting, setDeleting] = useState(false);
ab61b9d94 const [error, setError] = useState("");
ab61b9d95
6dd74de96 // Redirect if not logged in
8d8e81597 useEffect(() => {
6dd74de98 if (!authLoading && !user) {
6dd74de99 router.push(`/${owner}/${repo}`);
6dd74de100 }
6dd74de101 }, [authLoading, user, router, owner, repo]);
6dd74de102
6dd74de103 useEffect(() => {
6dd74de104 if (!user) return;
6dd74de105
8d8e815106 repos.get(owner, repo).then(({ repo: r }) => {
8d8e815107 setRepoData(r);
e5b523e108 setDomainInput(r.pages_domain ?? "");
6dd74de109
6dd74de110 // Check ownership
6dd74de111 if (r.owner_type === "user") {
6dd74de112 if (r.owner_name === user.username) {
6dd74de113 setAuthorized(true);
6dd74de114 } else {
6dd74de115 router.push(`/${owner}/${repo}`);
6dd74de116 }
6dd74de117 } else {
6dd74de118 // Org repo — check membership
6dd74de119 orgsApi
6dd74de120 .get(owner)
6dd74de121 .then(({ members }) => {
6dd74de122 if (members.some((m) => m.username === user.username)) {
6dd74de123 setAuthorized(true);
6dd74de124 } else {
6dd74de125 router.push(`/${owner}/${repo}`);
6dd74de126 }
6dd74de127 })
6dd74de128 .catch(() => router.push(`/${owner}/${repo}`));
6dd74de129 }
6dd74de130
8d8e815131 setLoading(false);
8d8e815132 }).catch(() => setLoading(false));
6dd74de133
926861d134 instances.list()
926861d135 .then(({ instances: list }) => {
926861d136 const active = list.find((i) => i.status === "active");
926861d137 if (active?.ip) setServerIp(active.ip);
926861d138 })
85f7497139 .catch(() => {});
6dd74de140 }, [owner, repo, user, router]);
8d8e815141
e5b523e142 async function updateSetting(data: { is_private?: boolean; require_diffs?: boolean; pages_enabled?: boolean; pages_domain?: string | null }) {
8d8e815143 setSaving(true);
8d8e815144 try {
8d8e815145 const { repo: updated } = await repos.update(owner, repo, data);
8d8e815146 setRepoData(updated);
8d8e815147 } catch (err: any) {
8d8e815148 setError(err.message ?? "Failed to update setting");
8d8e815149 } finally {
8d8e815150 setSaving(false);
8d8e815151 }
8d8e815152 }
8d8e815153
ab61b9d154 async function handleDelete() {
ab61b9d155 setError("");
ab61b9d156 setDeleting(true);
ab61b9d157 try {
ab61b9d158 await repos.delete(owner, repo);
ab61b9d159 router.push("/");
ab61b9d160 } catch (err: any) {
ab61b9d161 setError(err.message ?? "Failed to delete repository");
ab61b9d162 setDeleting(false);
ab61b9d163 }
ab61b9d164 }
ab61b9d165
6dd74de166 if (authLoading || loading || !authorized) {
8d8e815167 return (
8d8e815168 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
8d8e815169 Loading...
8d8e815170 </p>
8d8e815171 );
8d8e815172 }
8d8e815173
ab61b9d174 return (
ab61b9d175 <>
ab61b9d176 <h2 className="text-base mb-6" style={{ color: "var(--text-primary)" }}>
ab61b9d177 Settings
ab61b9d178 </h2>
ab61b9d179
8d8e815180 {/* General settings */}
8d8e815181 <div
8d8e815182 style={{
8d8e815183 border: "1px solid var(--border)",
8d8e815184 padding: "1rem",
8d8e815185 marginBottom: "1.5rem",
8d8e815186 }}
8d8e815187 >
8d8e815188 <h3
8d8e815189 className="text-sm mb-2"
8d8e815190 style={{ color: "var(--text-primary)" }}
8d8e815191 >
8d8e815192 General
8d8e815193 </h3>
8d8e815194
8d8e815195 <SettingRow
8d8e815196 label="Private repository"
8d8e815197 description="Only visible to you and organization members."
8d8e815198 >
8d8e815199 <Toggle
8d8e815200 checked={!!repoData?.is_private}
8d8e815201 onChange={(checked) => updateSetting({ is_private: checked })}
8d8e815202 disabled={saving}
8d8e815203 />
8d8e815204 </SettingRow>
8d8e815205
8d8e815206 <SettingRow
8d8e815207 label="Require diff reviews"
8d8e815208 description="Pushes automatically create diffs for code review."
8d8e815209 >
8d8e815210 <Toggle
8d8e815211 checked={!!repoData?.require_diffs}
8d8e815212 onChange={(checked) => updateSetting({ require_diffs: checked })}
8d8e815213 disabled={saving}
8d8e815214 />
8d8e815215 </SettingRow>
8d8e815216 </div>
8d8e815217
e5b523e218 {/* Pages settings */}
e5b523e219 <div
e5b523e220 style={{
e5b523e221 border: "1px solid var(--border)",
e5b523e222 padding: "1rem",
e5b523e223 marginBottom: "1.5rem",
e5b523e224 }}
e5b523e225 >
e5b523e226 <h3
e5b523e227 className="text-sm mb-2"
e5b523e228 style={{ color: "var(--text-primary)" }}
e5b523e229 >
e5b523e230 Pages
e5b523e231 </h3>
e5b523e232
e5b523e233 <SettingRow
e5b523e234 label="Enable Grove Pages"
e5b523e235 description="Serve this repository as a static website."
e5b523e236 >
e5b523e237 <Toggle
e5b523e238 checked={!!repoData?.pages_enabled}
e5b523e239 onChange={(checked) => updateSetting({ pages_enabled: checked })}
e5b523e240 disabled={saving}
e5b523e241 />
e5b523e242 </SettingRow>
e5b523e243
433bcf1244 {!!repoData?.pages_enabled && (
e5b523e245 <div className="py-3">
ff50d03246 {(() => {
ff50d03247 const defaultUrl = `https://pages.grove.host/${repoData?.owner_name}/${repoData?.name}`;
ff50d03248 const customUrl = repoData?.pages_domain ? `https://${repoData.pages_domain}` : null;
ff50d03249 return (
ff50d03250 <div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
ff50d03251 <p>
ff50d03252 Serving at{" "}
ff50d03253 <a
ff50d03254 href={defaultUrl}
ff50d03255 target="_blank"
ff50d03256 rel="noopener noreferrer"
ff50d03257 style={{ color: "var(--accent)" }}
ff50d03258 >
ff50d03259 {defaultUrl}
ff50d03260 </a>
ff50d03261 </p>
ff50d03262 {customUrl && (
ff50d03263 <p className="mt-1">
ff50d03264 Custom domain:{" "}
ff50d03265 <a
ff50d03266 href={customUrl}
ff50d03267 target="_blank"
ff50d03268 rel="noopener noreferrer"
ff50d03269 style={{ color: "var(--accent)" }}
ff50d03270 >
ff50d03271 {customUrl}
ff50d03272 </a>
ff50d03273 </p>
ff50d03274 )}
ff50d03275 </div>
ff50d03276 );
ff50d03277 })()}
e5b523e278 <p className="text-sm mb-1" style={{ color: "var(--text-primary)" }}>
ff50d03279 Custom domain (optional)
e5b523e280 </p>
e5b523e281 <p className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
85f7497282 Point your domain&apos;s A record to{" "}
85f7497283 {serverIp ? (
85f7497284 <code style={{ color: "var(--text-primary)" }}>{serverIp}</code>
85f7497285 ) : (
85f7497286 "this server\u2019s IP"
85f7497287 )}
85f7497288 , then enter it here.
e5b523e289 </p>
e5b523e290 <div className="flex gap-2 items-center">
e5b523e291 <Input
e5b523e292 value={domainInput}
e5b523e293 onChange={(e) => setDomainInput(e.target.value)}
e5b523e294 placeholder="example.com"
e5b523e295 style={{ flex: 1 }}
e5b523e296 />
e5b523e297 <Button
e5b523e298 onClick={() => updateSetting({ pages_domain: domainInput || null })}
e5b523e299 disabled={saving || domainInput === (repoData?.pages_domain ?? "")}
e5b523e300 >
e5b523e301 Save
e5b523e302 </Button>
e5b523e303 </div>
e5b523e304 </div>
e5b523e305 )}
e5b523e306 </div>
e5b523e307
8d8e815308 {/* Danger zone */}
ab61b9d309 <div
ab61b9d310 style={{
ab61b9d311 border: "1px solid var(--status-closed-border)",
ab61b9d312 padding: "1rem",
ab61b9d313 }}
ab61b9d314 >
ab61b9d315 <h3
ab61b9d316 className="text-sm mb-4"
ab61b9d317 style={{ color: "var(--status-closed-text)" }}
ab61b9d318 >
ab61b9d319 Danger zone
ab61b9d320 </h3>
ab61b9d321
ab61b9d322 <div className="flex items-center justify-between">
ab61b9d323 <div>
ab61b9d324 <p className="text-sm" style={{ color: "var(--text-primary)" }}>
ab61b9d325 Delete this repository
ab61b9d326 </p>
ab61b9d327 <p className="text-xs" style={{ color: "var(--text-muted)" }}>
ab61b9d328 Once deleted, this cannot be undone.
ab61b9d329 </p>
ab61b9d330 </div>
ab61b9d331 <Button variant="danger" onClick={() => setShowDelete(true)}>
ab61b9d332 Delete
ab61b9d333 </Button>
ab61b9d334 </div>
ab61b9d335 </div>
ab61b9d336
ab61b9d337 <Dialog
ab61b9d338 open={showDelete}
ab61b9d339 onClose={() => {
ab61b9d340 if (!deleting) {
ab61b9d341 setShowDelete(false);
ab61b9d342 setConfirmName("");
ab61b9d343 setError("");
ab61b9d344 }
ab61b9d345 }}
ab61b9d346 title="Delete repository"
ab61b9d347 actions={
ab61b9d348 <>
ab61b9d349 <Button
ab61b9d350 variant="secondary"
ab61b9d351 onClick={() => {
ab61b9d352 setShowDelete(false);
ab61b9d353 setConfirmName("");
ab61b9d354 setError("");
ab61b9d355 }}
ab61b9d356 disabled={deleting}
ab61b9d357 >
ab61b9d358 Cancel
ab61b9d359 </Button>
ab61b9d360 <Button
ab61b9d361 variant="danger"
ab61b9d362 onClick={handleDelete}
ab61b9d363 disabled={confirmName !== repo || deleting}
ab61b9d364 loading={deleting}
ab61b9d365 loadingText="Deleting..."
ab61b9d366 >
ab61b9d367 Delete this repository
ab61b9d368 </Button>
ab61b9d369 </>
ab61b9d370 }
ab61b9d371 >
ab61b9d372 <p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
ab61b9d373 This will permanently delete{" "}
ab61b9d374 <strong style={{ color: "var(--text-primary)" }}>
ab61b9d375 {owner}/{repo}
ab61b9d376 </strong>{" "}
ab61b9d377 and all associated data including diffs, pipelines, and secrets.
ab61b9d378 </p>
ab61b9d379
ab61b9d380 <Input
ab61b9d381 label={`Type "${repo}" to confirm`}
ab61b9d382 value={confirmName}
ab61b9d383 onChange={(e) => setConfirmName(e.target.value)}
ab61b9d384 placeholder={repo}
ab61b9d385 autoFocus
ab61b9d386 />
ab61b9d387
ab61b9d388 {error && (
ab61b9d389 <p
ab61b9d390 className="text-xs mt-3"
ab61b9d391 style={{ color: "var(--error-text)" }}
ab61b9d392 >
ab61b9d393 {error}
ab61b9d394 </p>
ab61b9d395 )}
ab61b9d396 </Dialog>
ab61b9d397 </>
ab61b9d398 );
ab61b9d399}