diff --git a/README.md b/README.md index a88e2f0..2822df2 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ SDK の役割: ```js USSP.config.url("https://storage.example.com") -await USSP.init({ clientId: "appId" }) +await USSP.init({ clientId: "com.example.billing" }) // = ClientSpace await USSP.upload("memo.txt", blob) ``` @@ -149,8 +149,11 @@ SDK は最小限の依存で安全に扱えるようにする。 最低限の UI 機能: * サーバー Storage Adapter 設定画面(Local/S3/R2/Drive など) -* OAuth クライアント登録・管理 -* Namespace・Storage Policy 設定 +* OAuth クライアント登録・管理(ClientID相当は `ClientSpace` として運用) +* OAuth 許可画面でサービスURLとClientSpaceをユーザーへ明示 +* ユーザー管理 +* 各種ログ確認 +* Namespace・保存先ストレージ選択・Storage Policy 設定 * 使用状況・クォータ表示 --- diff --git a/client/src/App.tsx b/client/src/App.tsx index 330885e..982c9ef 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import AdaptersPage from "@/pages/adapters"; import NamespacesPage from "@/pages/namespaces"; import ClientsPage from "@/pages/clients"; import UsersPage from "@/pages/users"; +import LogsPage from "@/pages/logs"; import NotFound from "@/pages/not-found"; function Router() { @@ -25,6 +26,7 @@ function Router() { + diff --git a/client/src/components/layout/app-layout.tsx b/client/src/components/layout/app-layout.tsx index 3273980..7ba8243 100644 --- a/client/src/components/layout/app-layout.tsx +++ b/client/src/components/layout/app-layout.tsx @@ -6,7 +6,8 @@ import { KeyRound, LayoutDashboard, Settings2, - UserCog + UserCog, + ScrollText } from "lucide-react"; import { Sidebar, @@ -28,6 +29,7 @@ const navItems = [ { title: "ネームスペース", url: "/namespaces", icon: FolderTree }, { title: "OAuthクライアント", url: "/clients", icon: KeyRound }, { title: "ユーザー", url: "/users", icon: UserCog }, + { title: "各種ログ", url: "/logs", icon: ScrollText }, ]; function AppSidebar() { diff --git a/client/src/hooks/use-admin-logs.ts b/client/src/hooks/use-admin-logs.ts new file mode 100644 index 0000000..c9f5e88 --- /dev/null +++ b/client/src/hooks/use-admin-logs.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +export interface AdminLogEntry { + timestamp: string; + method: string; + path: string; + statusCode: number; + durationMs: number; + response?: unknown; +} + +export function useAdminLogs(limit = 100) { + return useQuery({ + queryKey: ["/api/admin/logs", limit], + queryFn: async () => { + const res = await fetch(`/api/admin/logs?limit=${limit}`, { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch admin logs"); + return (await res.json()) as AdminLogEntry[]; + }, + refetchInterval: 5000, + }); +} diff --git a/client/src/hooks/use-namespaces.ts b/client/src/hooks/use-namespaces.ts index 46dd8de..dd831ea 100644 --- a/client/src/hooks/use-namespaces.ts +++ b/client/src/hooks/use-namespaces.ts @@ -69,3 +69,32 @@ export function useDeleteNamespace() { } }); } + +export function useUpdateNamespace() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async ({ id, data }: { id: number; data: { storageAdapterId?: number | null; quotaBytes?: number | null } }) => { + const url = buildUrl(api.namespaces.update.path, { id }); + const res = await fetch(url, { + method: api.namespaces.update.method, + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(data), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: "Failed to update namespace" })); + throw new Error(err.message || "Failed to update namespace"); + } + return api.namespaces.update.responses[200].parse(await res.json()); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [api.namespaces.list.path] }); + toast({ title: "Namespace updated" }); + }, + onError: (err) => { + toast({ title: "Failed to update namespace", description: err.message, variant: "destructive" }); + } + }); +} diff --git a/client/src/pages/clients.tsx b/client/src/pages/clients.tsx index f50db83..24b40cf 100644 --- a/client/src/pages/clients.tsx +++ b/client/src/pages/clients.tsx @@ -31,6 +31,7 @@ import type { OauthClient } from "@shared/schema"; const clientFormSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), + clientId: z.string().min(3, "ClientSpace は3文字以上で入力してください"), redirectUris: z.string().min(5, "Provide at least one redirect URI"), }); @@ -50,12 +51,12 @@ export default function ClientsPage() { OAuthクライアント -

Manage applications authorized to use the USSP SDK.

+

サービス提供者が設定する ClientSpace と OAuth 設定を管理します。

setNewClientDetails(client)} + onCreated={(client) => setNewClientDetails(client)} /> @@ -65,7 +66,7 @@ export default function ClientsPage() { App Name - クライアントID + ClientSpace Redirect URIs 作成日時 操作 @@ -122,7 +123,6 @@ export default function ClientsPage() { - {/* Secret Display Modal - Shown only immediately after creation */} {newClientDetails && ( void, - on作成日時: (client: OauthClient) => void + onCreated: (client: OauthClient) => void }) { const createMutation = useCreateClient(); @@ -148,36 +148,33 @@ function CreateClientDialog({ resolver: zodResolver(clientFormSchema), defaultValues: { name: "", + clientId: "", redirectUris: "http://localhost:3000/callback", }, }); function onSubmit(data: ClientFormValues) { - createMutation.mutate( - data, - { - onSuccess: (newClient) => { - onOpenChange(false); - form.reset(); - // Pass the returned client (which includes the unhashed secret) to parent - on作成日時(newClient as unknown as OauthClient); - } + createMutation.mutate(data, { + onSuccess: (newClient) => { + onOpenChange(false); + form.reset(); + onCreated(newClient); } - ); + }); } return ( OAuthクライアント登録 - Create credentials for a new application to use the USSP API. + ClientSpace はサービス提供者が任意に決める識別子です。OAuth 承認画面でユーザーへ明示されます。 @@ -188,23 +185,38 @@ function CreateClientDialog({ name="name" render={({ field }) => ( - Application Name + App Name - + )} /> + ( + + ClientSpace + + + + 既存 ClientID 相当の値です。サービス提供者が独自設定してください。 + + + )} + /> + ( - Redirect URIs (comma separated) + Redirect URI(s) - + Where to send users after authorization. @@ -214,7 +226,7 @@ function CreateClientDialog({
@@ -228,10 +240,14 @@ function SecretRevealDialog({ client, onClose }: { client: OauthClient, onClose: const [copiedId, setCopiedId] = useState(false); const [copiedSecret, setCopiedSecret] = useState(false); - const copyToClipboard = (text: string, setter: (val: boolean) => void) => { - navigator.clipboard.writeText(text); - setter(true); - setTimeout(() => setter(false), 2000); + const copyToClipboard = async (text: string, setCopied: (val: boolean) => void) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // no-op + } }; return ( @@ -239,36 +255,36 @@ function SecretRevealDialog({ client, onClose }: { client: OauthClient, onClose: - Client Registered + OAuth Client created - Store these credentials securely. The クライアントシークレット will never be shown again. + この情報は一度だけ表示されます。安全な場所に保存してください。 -
-
- +
+
+

ClientSpace

- -
- + +
+

Client Secret

- -
+ +

シークレットは今すぐコピーしてください。紛失した場合はクライアントを再作成してください。

diff --git a/client/src/pages/logs.tsx b/client/src/pages/logs.tsx new file mode 100644 index 0000000..077070b --- /dev/null +++ b/client/src/pages/logs.tsx @@ -0,0 +1,58 @@ +import { useAdminLogs } from "@/hooks/use-admin-logs"; +import { Card, CardContent } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ScrollText } from "lucide-react"; +import { format } from "date-fns"; + +export default function LogsPage() { + const { data: logs, isLoading } = useAdminLogs(150); + + return ( +
+
+

+ + 各種ログ確認 +

+

直近の API リクエストログを確認できます(自動更新)。

+
+ + + + + + + Time + Method + Path + Status + Duration + + + + {isLoading ? ( + + Loading logs... + + ) : logs?.length ? ( + logs.map((entry, idx) => ( + + {format(new Date(entry.timestamp), "yyyy-MM-dd HH:mm:ss")} + {entry.method} + {entry.path} + {entry.statusCode} + {entry.durationMs}ms + + )) + ) : ( + + No logs + + )} + +
+
+
+
+ ); +} diff --git a/client/src/pages/namespaces.tsx b/client/src/pages/namespaces.tsx index 4f815ba..973b7fe 100644 --- a/client/src/pages/namespaces.tsx +++ b/client/src/pages/namespaces.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useNamespaces, useCreateNamespace, useDeleteNamespace } from "@/hooks/use-namespaces"; +import { useNamespaces, useCreateNamespace, useDeleteNamespace, useUpdateNamespace } from "@/hooks/use-namespaces"; import { useAdapters } from "@/hooks/use-adapters"; import { z } from "zod"; import { useForm } from "react-hook-form"; @@ -48,6 +48,7 @@ export default function ネームスペースPage() { const { data: namespaces, isLoading } = useNamespaces(); const { data: adapters } = useAdapters(); const deleteMutation = useDeleteNamespace(); + const updateMutation = useUpdateNamespace(); const [isDialogOpen, setIsDialogOpen] = useState(false); return ( @@ -69,7 +70,7 @@ export default function ネームスペースPage() { Name - Adapter + 保存先ストレージ Quota 作成日時 操作 @@ -88,21 +89,33 @@ export default function ネームスペースPage() { ) : ( namespaces?.map((ns) => { - const adapter = adapters?.find(a => a.id === ns.storageAdapterId); return ( {ns.name} - {adapter ? ( -
- {adapter.name} - {adapter.type} -
- ) : ( - Default - )} +
{ns.quotaBytes ? formatBytes(ns.quotaBytes) : Unlimited} diff --git a/docs/README.md b/docs/README.md index 46f99de..8819490 100644 --- a/docs/README.md +++ b/docs/README.md @@ -180,8 +180,10 @@ npm run dev 1. セットアップ 2. ストレージ選択(Local/S3/など) 3. ユーザー作成 -4. OAuth クライアント登録 -5. セキュリティ設定 +4. 各種ログ確認 +5. OAuth クライアント(ClientSpace)登録 +6. ネームスペースの保存先ストレージ選択 +7. セキュリティ設定 --- diff --git a/docs/SERVER_GUIDE.md b/docs/SERVER_GUIDE.md index 8b7b8c2..1f8fd3e 100644 --- a/docs/SERVER_GUIDE.md +++ b/docs/SERVER_GUIDE.md @@ -204,9 +204,10 @@ Google Drive連携対応予定。 ### OAuth クライアント運用方針(推奨) -- 開発者は任意の `client_id` をSDK設定に指定します。 -- サーバー側で事前登録しなくても、初回OAuth時に `client_id` をキーとしてOAuthクライアントとnamespaceを自動作成します。 -- `redirect_uri` はリクエスト時の値へそのままリダイレクトされます。 +- 開発者は任意の `client_id` を設定しますが、USSPではこの値を **ClientSpace** として扱います。 +- 本番/開発を問わず動的登録され、初回OAuth時に `client_id(ClientSpace)` をキーとしてOAuthクライアントとnamespaceを自動作成します。 +- OAuth承認画面では、ユーザーに `サービスURL` と `ClientSpace` を明示して明示的な許可操作を要求します。 +- `redirect_uri` はクライアントの許可済みURIとして自動追記され、承認後に該当URIへリダイレクトされます。 ### クライアント登録 @@ -588,3 +589,14 @@ tail -f data/logs/app.log | grep -i backup ## デプロイメント 詳細は [DEPLOYMENT.md](./DEPLOYMENT.md) を参照してください。 + + +## Web UI 管理機能 + +現在の Web UI では以下を管理できます。 + +- ユーザー管理(作成/ロール変更/有効無効) +- 各種ログ確認(直近 API リクエストログ) +- ネームスペースごとの保存先ストレージ選択 +- ストレージアダプター設定 +- OAuth クライアント(ClientSpace)管理 diff --git a/server/index.ts b/server/index.ts index d3cca08..f412b07 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,6 +5,7 @@ import { createServer } from "http"; import fs from "fs"; import path from "path"; import { backupProcessor } from "./backup-queue"; +import { recordRequestLog } from "./request-logs"; const app = express(); const httpServer = createServer(app); @@ -55,6 +56,14 @@ app.use((req, res, next) => { logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`; } + recordRequestLog({ + timestamp: new Date().toISOString(), + method: req.method, + path, + statusCode: res.statusCode, + durationMs: duration, + response: capturedJsonResponse, + }); log(logLine); } }); diff --git a/server/request-logs.ts b/server/request-logs.ts new file mode 100644 index 0000000..567c128 --- /dev/null +++ b/server/request-logs.ts @@ -0,0 +1,22 @@ +export interface RequestLogEntry { + timestamp: string; + method: string; + path: string; + statusCode: number; + durationMs: number; + response?: unknown; +} + +const MAX_LOGS = 300; +const requestLogs: RequestLogEntry[] = []; + +export function recordRequestLog(entry: RequestLogEntry) { + requestLogs.push(entry); + if (requestLogs.length > MAX_LOGS) { + requestLogs.splice(0, requestLogs.length - MAX_LOGS); + } +} + +export function getRecentRequestLogs(limit = 100): RequestLogEntry[] { + return requestLogs.slice(-Math.max(1, limit)).reverse(); +} diff --git a/server/routes.ts b/server/routes.ts index 469ad1e..581fbed 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -4,10 +4,8 @@ import { storage } from "./storage"; import { api } from "@shared/routes"; import { z } from "zod"; import { - generatePKCEChallenge, createAuthorizationCode, exchangeCodeForToken, - validateAccessToken, } from "./oauth"; import { fileHandler } from "./file-handler"; import { backupProcessor } from "./backup-queue"; @@ -16,11 +14,11 @@ import { requireAdminSession, requireOAuthToken, adminOnly, - requireFileAccess, createAdminSession, destroyAdminSession, type AuthenticatedRequest, } from "./middleware/security"; +import { getRecentRequestLogs } from "./request-logs"; export async function registerRoutes( httpServer: Server, @@ -104,6 +102,11 @@ export async function registerRoutes( res.status(204).end(); }); + app.get("/api/admin/logs", requireAdminSession as any, adminOnly as any, async (req, res) => { + const limit = Number(req.query.limit ?? 100); + res.json(getRecentRequestLogs(Number.isFinite(limit) ? limit : 100)); + }); + // Adapters (Web UI Admin Only) app.get(api.adapters.list.path, requireAdminSession as any, adminOnly as any, async (req, res) => { const adapters = await storage.getAdapters(); @@ -156,6 +159,26 @@ export async function registerRoutes( res.status(204).end(); }); + app.patch("/api/namespaces/:id", requireAdminSession as any, adminOnly as any, async (req, res) => { + try { + const schema = z.object({ + storageAdapterId: z.coerce.number().nullable().optional(), + quotaBytes: z.coerce.number().nullable().optional(), + }); + const updateData = schema.parse(req.body); + const ns = await storage.updateNamespace(Number(req.params.id), updateData); + if (!ns) { + return res.status(404).json({ error: "Namespace not found" }); + } + res.json(ns); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ message: err.errors[0].message, field: err.errors[0].path.join('.') }); + } + throw err; + } + }); + // Clients (Web UI Admin Only) app.get(api.clients.list.path, requireAdminSession as any, adminOnly as any, async (req, res) => { const clients = await storage.getClients(); @@ -194,41 +217,89 @@ export async function registerRoutes( // OAuth Endpoints app.get("/oauth/authorize", async (req, res) => { try { - const clientId = req.query.client_id as string; + const clientSpace = req.query.client_id as string; const redirectUri = req.query.redirect_uri as string; const codeChallenge = req.query.code_challenge as string; const codeChallengeMethod = (req.query.code_challenge_method as string) || "plain"; const state = req.query.state as string; - if (!clientId || !redirectUri) { + if (!clientSpace || !redirectUri || !codeChallenge) { return res.status(400).json({ error: "Missing required parameters" }); } - // Auto-provision OAuth client and namespace from developer-defined client_id - const client = await storage.ensureOAuthClient(clientId, redirectUri); - await storage.ensureNamespaceForClient(clientId); + const client = await storage.ensureOAuthClient(clientSpace, redirectUri); + await storage.ensureNamespaceForClient(clientSpace); - // Verify redirect URI after provisioning if (!client.redirectUris.split(",").map((uri) => uri.trim()).includes(redirectUri)) { return res.status(400).json({ error: "Invalid redirect_uri" }); } - // Generate authorization code + const serviceUrl = `${req.protocol}://${req.get("host")}`; + const params = new URLSearchParams({ + client_id: clientSpace, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + }); + if (state) params.set("state", state); + + const hiddenInputs = Array.from(params.entries()) + .map(([key, value]) => ``) + .join(""); + + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.send(` + + USSP OAuth 承認 + +

USSP アクセス許可

+

以下のアプリケーションからアクセス要求が来ています。サービス提供者が設定した ClientSpace を確認して許可してください。

+
    +
  • サービスURL: ${serviceUrl}
  • +
  • ClientSpace: ${clientSpace}
  • +
  • アプリ名: ${client.name}
  • +
+
+ ${hiddenInputs} + + +
+ +`); + } catch (err) { + console.error("OAuth authorize error:", err); + res.status(500).json({ error: "Internal server error" }); + } + }); + + app.post("/oauth/authorize/consent", async (req, res) => { + try { + const { decision, client_id, redirect_uri, code_challenge, code_challenge_method, state } = req.body; + + if (!client_id || !redirect_uri || !code_challenge) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + if (decision !== "approve") { + const denied = new URL(redirect_uri); + denied.searchParams.set("error", "access_denied"); + if (state) denied.searchParams.set("state", state); + return res.redirect(denied.toString()); + } + const code = await createAuthorizationCode( - clientId, - redirectUri, - codeChallenge, - codeChallengeMethod as "S256" | "plain" + client_id, + redirect_uri, + code_challenge, + (code_challenge_method || "plain") as "S256" | "plain" ); - // Redirect to client with code - const redirectUrl = new URL(redirectUri); + const redirectUrl = new URL(redirect_uri); redirectUrl.searchParams.set("code", code); if (state) redirectUrl.searchParams.set("state", state); - res.redirect(redirectUrl.toString()); } catch (err) { - console.error("OAuth authorize error:", err); + console.error("OAuth consent error:", err); res.status(500).json({ error: "Internal server error" }); } }); @@ -523,6 +594,7 @@ export async function seedDatabase() { await storage.createClient({ name: "Demo Web App", + clientId: "demo.web.app", redirectUris: "http://localhost:5000/callback", }); } diff --git a/server/storage.ts b/server/storage.ts index 27d7f67..b655f4f 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -14,15 +14,16 @@ export interface IStorage { getNamespaces(): Promise; createNamespace(ns: InsertNamespace): Promise; + updateNamespace(id: number, ns: Partial): Promise; deleteNamespace(id: number): Promise; getClients(): Promise; getClientByClientId(clientId: string): Promise; - ensureOAuthClient(clientId: string, redirectUri: string): Promise; + ensureOAuthClient(clientSpace: string, redirectUri: string): Promise; createClient(client: InsertOauthClient): Promise; deleteClient(id: number): Promise; - ensureNamespaceForClient(clientId: string): Promise; + ensureNamespaceForClient(clientSpace: string): Promise; getFiles(): Promise; @@ -78,6 +79,16 @@ export class DatabaseStorage implements IStorage { const [created] = await db.insert(namespaces).values(ns).returning(); return created; } + + async updateNamespace(id: number, ns: Partial): Promise { + const [updated] = await db + .update(namespaces) + .set(ns) + .where(eq(namespaces.id, id)) + .returning(); + + return updated ?? null; + } async deleteNamespace(id: number): Promise { await db.delete(namespaces).where(eq(namespaces.id, id)); @@ -97,15 +108,15 @@ export class DatabaseStorage implements IStorage { return client ?? null; } - async ensureOAuthClient(clientId: string, redirectUri: string): Promise { - const existing = await this.getClientByClientId(clientId); + async ensureOAuthClient(clientSpace: string, redirectUri: string): Promise { + const existing = await this.getClientByClientId(clientSpace); if (!existing) { const clientSecret = crypto.randomBytes(32).toString('hex'); const [created] = await db .insert(oauthClients) .values({ - name: `OAuth Client ${clientId}`, - clientId, + name: `OAuth ClientSpace ${clientSpace}`, + clientId: clientSpace, clientSecret, redirectUris: redirectUri, }) @@ -134,12 +145,10 @@ export class DatabaseStorage implements IStorage { } async createClient(client: InsertOauthClient): Promise { - const clientId = crypto.randomBytes(16).toString('hex'); const clientSecret = crypto.randomBytes(32).toString('hex'); - + const [created] = await db.insert(oauthClients).values({ ...client, - clientId, clientSecret, }).returning(); return created; @@ -149,11 +158,11 @@ export class DatabaseStorage implements IStorage { await db.delete(oauthClients).where(eq(oauthClients.id, id)); } - async ensureNamespaceForClient(clientId: string): Promise { + async ensureNamespaceForClient(clientSpace: string): Promise { const [existingNamespace] = await db .select() .from(namespaces) - .where(eq(namespaces.name, clientId)) + .where(eq(namespaces.name, clientSpace)) .limit(1); if (existingNamespace) { @@ -169,7 +178,7 @@ export class DatabaseStorage implements IStorage { const [created] = await db .insert(namespaces) .values({ - name: clientId, + name: clientSpace, storageAdapterId: defaultAdapter?.id, }) .returning(); diff --git a/shared/routes.ts b/shared/routes.ts index 8b33b7a..9ea6e28 100644 --- a/shared/routes.ts +++ b/shared/routes.ts @@ -42,7 +42,16 @@ export const api = { method: 'DELETE' as const, path: '/api/namespaces/:id' as const, responses: { 204: z.void(), 404: errorSchemas.notFound }, - } + }, + update: { + method: 'PATCH' as const, + path: '/api/namespaces/:id' as const, + input: z.object({ + storageAdapterId: z.number().nullable().optional(), + quotaBytes: z.number().nullable().optional(), + }), + responses: { 200: z.custom(), 400: errorSchemas.validation, 404: errorSchemas.notFound }, + }, }, clients: { list: { diff --git a/shared/schema.ts b/shared/schema.ts index 6f78fb7..eae406f 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -77,7 +77,7 @@ export const backupQueue = pgTable("backup_queue", { export const insertStorageAdapterSchema = createInsertSchema(storageAdapters).omit({ id: true, createdAt: true }); // For forms, config is often typed as a string then parsed to JSON, but we'll accept any object here export const insertNamespaceSchema = createInsertSchema(namespaces).omit({ id: true, createdAt: true }); -export const insertOauthClientSchema = createInsertSchema(oauthClients).omit({ id: true, clientId: true, clientSecret: true, createdAt: true }); +export const insertOauthClientSchema = createInsertSchema(oauthClients).omit({ id: true, clientSecret: true, createdAt: true }); export type User = typeof users.$inferSelect; export type StorageAdapter = typeof storageAdapters.$inferSelect;