Files
altdock/components/altdock/admin-shell.tsx
T
amania-jailbreak f9ce4b218e auth: SSOログイン後の画面遷移とユーザー表示を修正
- コールバック成功時のリダイレクト先をWEB_ORIGINルートから
  /dashboardに変更(ランディングで止まらないように)
- ルートページでログイン済みならダッシュボードへ自動遷移
- サイドバーのユーザーカードをOIDC identityの表示名・メールに変更し、
  未ログイン時のみSSOログインリンクを表示
- demoヘッダ(x-demo-user)はdemoモード時のみ送信
2026-08-05 16:07:10 +09:00

117 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import * as Toast from "@radix-ui/react-toast";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { useEffect, useMemo, useState, type ComponentType, type PropsWithChildren } from "react";
import { AppWindow, BookOpen, Boxes, ChevronDown, ExternalLink, FileArchive, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings2, UploadCloud, X } from "lucide-react";
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
import type { Identity } from "@/packages/core/src/index";
import { useDashboard } from "./dashboard-provider";
type NavItem = { href: string; label: string; icon: ComponentType<{ size?: number; strokeWidth?: number }> };
const primaryNav: NavItem[] = [
{ href: "/dashboard", label: "概要", icon: Gauge },
{ href: "/dashboard/sources", label: "Sources", icon: Boxes },
{ href: "/dashboard/apps", label: "アプリ", icon: AppWindow },
{ href: "/dashboard/releases", label: "リリース", icon: FileArchive },
];
const secondaryNav: NavItem[] = [
{ href: "https://faq.altstore.io/developers/distribute-with-altstore-pal", label: "PALドキュメント", icon: BookOpen },
{ href: "/dashboard/settings", label: "設定", icon: Settings2 },
];
function isActive(pathname: string, href: string) {
if (href === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(href);
}
function SideNavigation({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
const pathname = usePathname();
const router = useRouter();
const { dashboard } = useDashboard();
const { identity } = useIdentity();
const renderItem = (item: NavItem) => {
const external = item.href.startsWith("http");
const Icon = item.icon;
const className = `admin-nav-item${isActive(pathname, item.href) ? " active" : ""}`;
const content = <><Icon size={16} strokeWidth={1.8} /><span>{item.label}</span>{external && <ExternalLink className="admin-nav-external" size={12} />}</>;
if (external) return <a key={item.href} className={className} href={item.href} target="_blank" rel="noreferrer" onClick={onNavigate}>{content}</a>;
return <Link key={item.href} className={className} href={item.href} onClick={onNavigate}>{content}</Link>;
};
const storagePercent = dashboard ? Math.min(100, (dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes)) * 100) : 0;
return <div className="admin-sidebar-inner">
<button className="admin-brand" onClick={() => router.push("/dashboard")} aria-label="AltDock 概要へ移動"><span className="admin-brand-mark">A</span><span className={collapsed ? "sr-only" : "admin-brand-name"}>AltDock</span><span className={collapsed ? "sr-only" : "admin-brand-beta"}>BETA</span></button>
{!collapsed && <div className="admin-workspace-switcher"><span className="admin-workspace-avatar">{dashboard?.workspace.name.slice(0, 1).toUpperCase() || "D"}</span><span className="admin-workspace-copy"><small>WORKSPACE</small><strong>{dashboard?.workspace.name || "Developer Workspace"}</strong></span><ChevronDown size={14} /></div>}
<div className={collapsed ? "sr-only" : "admin-nav-label"}>管理</div>
<nav className="admin-nav" aria-label="管理画面ナビゲーション">{primaryNav.map(renderItem)}</nav>
<div className={collapsed ? "sr-only" : "admin-nav-label admin-nav-label-secondary"}>その他</div>
<nav className="admin-nav admin-nav-secondary" aria-label="その他のナビゲーション">{secondaryNav.map(renderItem)}</nav>
<div className="admin-sidebar-spacer" />
{!collapsed && <div className="admin-storage-card"><div className="admin-storage-row"><span>保存容量</span><strong>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</strong></div><div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div><small>Free workspace plan</small></div>}
{!collapsed && <div className="admin-user-card"><span className="admin-user-avatar">{(identity?.displayName || "D").slice(0, 1).toUpperCase()}</span><span><strong>{identity?.displayName || "Demo Developer"}</strong><small>{identity?.email || "demo@altdock.local"}</small></span><span className="admin-user-menu">···</span></div>}
</div>;
}
function useIdentity() {
const [identity, setIdentity] = useState<Identity | null>(null);
useEffect(() => {
if (AUTH_MODE !== "oidc") return;
let cancelled = false;
fetch(`${API_BASE}/api/v1/me`, { credentials: "include" })
.then((response) => response.ok ? response.json() : null)
.then((body) => { if (!cancelled && body?.identity) setIdentity(body.identity as Identity); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
return { identity };
}
function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const router = useRouter();
const commands = useMemo(() => primaryNav.map((item) => ({ ...item, action: () => router.push(item.href) })), [router]);
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="command-dialog"><DialogTitle className="sr-only">画面を検索</DialogTitle><DialogDescription className="sr-only">移動先を検索できます。</DialogDescription><Command label="画面を検索"><CommandInput placeholder="画面や操作を検索…" autoFocus /><CommandList><CommandEmpty>該当する画面がありません。</CommandEmpty><CommandGroup heading="移動"><CommandItem onSelect={() => { commands[0].action(); onOpenChange(false); }}><Gauge size={15} />概要<span className="command-shortcut">G O</span></CommandItem><CommandItem onSelect={() => { commands[1].action(); onOpenChange(false); }}><Boxes size={15} />Sources<span className="command-shortcut">G S</span></CommandItem><CommandItem onSelect={() => { commands[2].action(); onOpenChange(false); }}><AppWindow size={15} />アプリ<span className="command-shortcut">G A</span></CommandItem><CommandItem onSelect={() => { commands[3].action(); onOpenChange(false); }}><FileArchive size={15} />リリース<span className="command-shortcut">G R</span></CommandItem></CommandGroup><CommandGroup heading="操作"><CommandItem onSelect={() => { router.push("/dashboard/releases?upload=1"); onOpenChange(false); }}><UploadCloud size={15} />ADPをアップロード</CommandItem></CommandGroup></CommandList></Command></DialogContent></Dialog>;
}
export function AdminShell({ children }: PropsWithChildren) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [commandOpen, setCommandOpen] = useState(false);
const pathname = usePathname();
const { notice, dismissNotice, dashboard } = useDashboard();
const { identity } = useIdentity();
useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setCommandOpen(true); }
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, []);
const pageLabel = pathname === "/dashboard" ? "概要" : pathname.includes("/sources") ? "Sources" : pathname.includes("/apps") ? "アプリ" : pathname.includes("/releases") ? "リリース" : "設定";
return <Toast.Provider swipeDirection="right">
<div className="admin-shell">
<aside className={`admin-sidebar ${collapsed ? "is-collapsed" : ""}`}><SideNavigation collapsed={collapsed} /></aside>
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}><DialogContent className="mobile-nav-dialog"><DialogTitle className="sr-only">管理画面ナビゲーション</DialogTitle><DialogDescription className="sr-only">管理画面の移動先を選択します。</DialogDescription><button className="mobile-nav-close" onClick={() => setMobileOpen(false)} aria-label="ナビゲーションを閉じる"><X size={17} /></button><SideNavigation onNavigate={() => setMobileOpen(false)} /></DialogContent></Dialog>
<section className="admin-main-column">
<header className="admin-topbar"><div className="admin-topbar-left"><Button className="mobile-menu-button" variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="ナビゲーションを開く"><Menu size={18} /></Button><div className="admin-breadcrumb"><span>Workspace</span><span>/</span><strong>{pageLabel}</strong></div></div><div className="admin-topbar-actions"><button className="admin-search-trigger" onClick={() => setCommandOpen(true)}><Search size={15} /><span>検索</span><kbd> K</kbd></button><Link className="admin-topbar-action" href="/dashboard/releases?upload=1"><Plus size={15} />新しいリリース</Link>{AUTH_MODE === "oidc" ? (!identity ? <a className="admin-auth-link" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : null) : <a className="admin-auth-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">API status</a>}</div></header>
<div className="admin-content">{children}</div>
</section>
<button className="admin-collapse-toggle" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"}>{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}</button>
</div>
{notice && <Toast.Root open onOpenChange={(open) => { if (!open) dismissNotice(); }} className={`admin-toast toast-${notice.tone}`}><Toast.Title>{notice.tone === "error" ? "処理に失敗しました" : notice.tone === "info" ? "処理中" : "完了"}</Toast.Title><Toast.Description>{notice.text}</Toast.Description><Toast.Close aria-label="通知を閉じる">×</Toast.Close></Toast.Root>}
<Toast.Viewport className="admin-toast-viewport" />
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
</Toast.Provider>;
}