Changes
6 changed files (+333/-19)
-
-
@@ -1,13 +1,43 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { create, type MessageInitShape } from "@bufbuild/protobuf"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { mock, type MockOptions, } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; export const alice = create(UserSchema, { id: { value: "wu-alice" }, displayName: "Alice", isAdmin: true, permissions: { canAddUser: true, canDeleteRegularUser: true, canReadOtherUserProfile: true, canUpdateOtherRegularUserLoginMethod: true, canUpdateOtherRegularUserProfile: true, canUpdateSelfProfile: true, canUpdateWorkspace: true, }, loginMethod: { passwordConfigured: true, }, }); export const bob = create(UserSchema, { id: { value: "wu-bob" }, displayName: "Bob", permissions: { canUpdateSelfProfile: true, }, loginMethod: { passwordConfigured: true, }, }); export interface CreateInitialAdminOptions extends MockOptions { failureRate?: number;
-
@@ -141,17 +171,56 @@return { result: { case: "ok" as const, value: { id: { value: "ws-" + crypto.randomUUID(), }, name: "alice", displayName: "Alice", isAdmin: true, loginMethod: { passwordConfigured: true, }, }, value: alice, }, }; }, options, ); } export interface GetOptions extends MockOptions { failureRate?: number; workspace?: Extract< MessageInitShape<typeof WorkspaceService.method.get.output>["result"], { case: "ok" } >["value"]; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.get.output>["result"], { case: "ok" | undefined } >; } export function Get({ failureRate = 0, workspace = { displayName: "Mock Workspace", users: [alice, bob], }, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: GetOptions = {}) { return mock( WorkspaceService.method.get, () => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: workspace, }, }; },
-
-
-
@@ -14,12 +14,15 @@ ScrollArea,Text, } from "@radix-ui/themes"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type FC, type ReactNode, useCallback, useState } from "react"; import { type FC, type ReactNode, use, useCallback, useState } from "react"; import * as CopyrightNotice from "../components/CopyrightNotice.ts"; import { Logo } from "../components/Logo.tsx"; import * as NavigationMenu from "../components/NavigationMenu.ts"; import { URLContext } from "../contexts/Router.tsx"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import * as users from "./users/page.tsx"; import css from "./LoggedInLayout.module.css";
-
@@ -57,6 +60,7 @@ user,...rest }) => { const viewTransition = useViewTransition(); const url = use(URLContext); const [isMenuOpened, setIsMenuOpened] = useState(defaultMenuOpened);
-
@@ -115,12 +119,14 @@ ><Box asChild flexGrow="1" flexShrink="1"> <ScrollArea> <NavigationMenu.Root> <NavigationMenu.Item current> <NavigationMenu.Item> <span>ホーム</span> </NavigationMenu.Item> <NavigationMenu.Group title="ユーザ管理"> <NavigationMenu.Item> <span>ユーザ追加</span> <NavigationMenu.Item current={users.pattern.test(url)}> <a href={users.createHref()}> <users.Title /> </a> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root>
-
-
-
@@ -5,7 +5,11 @@ import type { Meta, StoryObj } from "@storybook/react";import { withInmemoryRouter } from "../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { GetLoginUser, Login } from "../mocks/yamori/workspace/v2/workspace_service.ts"; import { Get, GetLoginUser, Login, } from "../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx";
-
@@ -14,7 +18,7 @@ component: Page,parameters: { layout: "fullscreen", }, decorators: [withMockedBackend([GetLoginUser(), Login()])], decorators: [withMockedBackend([GetLoginUser(), Login(), Get()])], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>;
-
-
-
@@ -10,6 +10,7 @@import * as Empty from "../components/Empty.ts"; import { Select } from "../contexts/Router.tsx"; import * as users from "./users/page.tsx"; import * as root from "./root/page.tsx"; import { LoggedInLayout } from "./LoggedInLayout.tsx";
-
@@ -22,7 +23,12 @@ }return ( <Select routes={[]} routes={[ { pattern: users.pattern, children: <users.Page loginUser={loginUser} />, }, ]} fallback={ <LoggedInLayout title="404" user={loginUser}> <Empty.Root>
-
-
-
@@ -0,0 +1,54 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Get } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; export default { component: Page, parameters: { layout: "fullscreen", }, args: { loginUser: create(UserSchema, { id: { value: "wu-alice" }, displayName: "Alice", permissions: { canReadOtherUserProfile: true, }, }), }, decorators: [withInmemoryRouter({ initialURL: "/users" })], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([Get()])], }; export const NoPermission: Story = { args: { loginUser: create(UserSchema, { id: { value: "wu-alice" }, displayName: "Alice", permissions: {}, }), }, decorators: [withMockedBackend([Get()])], }; export const SystemError: Story = { decorators: [withMockedBackend([Get({ failureRate: 1 })])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([Get({ delayMs: 2_000 })])], };
-
-
-
@@ -0,0 +1,175 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../polyfill.ts"; import { createClient } from "@connectrpc/connect"; import { Button, Container, Flex, Separator, Spinner, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, Fragment } from "react"; import * as Empty from "../../components/Empty.ts"; import * as HelpDialog from "../../components/HelpDialog.ts"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { LoggedInLayout } from "../LoggedInLayout.tsx"; export const Title: FC = () => "ユーザ一覧"; export function hasAccess(loginUser: User): boolean { return !!loginUser.permissions?.canReadOtherUserProfile; } export const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>閲覧権限がありません</Empty.Title> <Empty.Description> 他のユーザの閲覧権限がないためページを表示できません。 </Empty.Description> </Empty.Root> ); }; export const Body: FC = () => { const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const users = useQuery({ queryKey: [WorkspaceService.typeName, WorkspaceService.method.get.name], async queryFn() { const resp = await client.get({}); if (resp.result.case === "ok") { return resp.result.value.users; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, }); if (!users.data) { if (users.error) { return ( <Empty.Root> <Empty.Title>ユーザ一覧を読み込めませんでした</Empty.Title> <ManagedErrorCallout error={users.error} title="ユーザ一覧を取得中にエラーが発生しました" /> <Empty.Actions> <Button loading={users.isFetching} onClick={() => void users.refetch()}> 再取得 </Button> </Empty.Actions> </Empty.Root> ); } if (users.isFetching) { return ( <Flex mt="5" justify="center" align="center" gap="2"> <Spinner /> <Text size="2" color="gray"> 一覧を取得中... </Text> </Flex> ); } return ( <Empty.Root> <Empty.Title>ユーザが存在しません</Empty.Title> <Empty.Description> ユーザ一覧のデータが空です。 システム不整合のため管理者に連絡してください。 </Empty.Description> </Empty.Root> ); } return ( <Flex direction="column" mt="5" gap="5"> {users.error && ( <ManagedErrorCallout severity="warning" title="一覧の更新取得に失敗しました" error={users.error} actions={ <Button size="1" loading={users.isFetching} onClick={() => void users.refetch()} > 再試行 </Button> } /> )} <Flex direction="column" gap="4" role="list"> {users.data.map((user, i) => { return ( <Fragment key={user.id?.value}> {i > 0 && <Separator size="4" />} <Flex direction="column" gap="2" role="listitem"> <Text weight="bold">{user.displayName}</Text> </Flex> </Fragment> ); })} </Flex> </Flex> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { return ( <HelpDialog.Root> <LoggedInLayout title={ <Flex as="span" align="center" gap="2"> <Title /> <HelpDialog.Trigger>このページの説明</HelpDialog.Trigger> </Flex> } user={loginUser} > <Container p="2" size="2"> {hasAccess(loginUser) ? <Body /> : <NoAccess />} </Container> </LoggedInLayout> <HelpDialog.Content> <HelpDialog.Title> <Title /> </HelpDialog.Title> <HelpDialog.Description> システムに登録されているユーザの一覧です。 </HelpDialog.Description> <HelpDialog.Paragraph> システムに登録されているすべてのユーザが、ログインの可能・不可能に関わらず表示されます。 他のユーザの閲覧権限がない場合は利用できません。 </HelpDialog.Paragraph> </HelpDialog.Content> </HelpDialog.Root> ); }; export function createHref(): string { return "/users"; } export const pattern = new URLPattern({ pathname: "/users", });
-