Changes
10 changed files (+452/-8)
-
-
@@ -66,6 +66,8 @@ "packages/react_ui": {"name": "@yamori/react_ui", "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2",
-
@@ -87,6 +89,7 @@ "@storybook/test": "^8.4.7","@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "@yamori/backend": "packages/backend", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2",
-
@@ -503,7 +506,7 @@ "@yamori/proto": ["@yamori/proto@workspace:packages/proto", { "devDependencies": { "@bufbuild/protobuf": "^2.2.2", "@bufbuild/protoc-gen-es": "^2.2.2" }, "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }],"@yamori/pwa": ["@yamori/pwa@workspace:packages/pwa", { "dependencies": { "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@yamori/backend": "packages/backend", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "@yamori/react_ui": "packages/react_ui", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "rollup-plugin-license": "^3.5.3", "typescript": "^5.7.2", "vite": "^6.0.2", "zod": "^3.24.1" } }], "@yamori/react_ui": ["@yamori/react_ui@workspace:packages/react_ui", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2", "vite": "^6.0.2" }, "peerDependencies": { "react": "19.x.x", "react-dom": "19.x.x" } }], "@yamori/react_ui": ["@yamori/react_ui@workspace:packages/react_ui", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "@yamori/backend": "packages/backend", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2", "vite": "^6.0.2" }, "peerDependencies": { "react": "19.x.x", "react-dom": "19.x.x" } }], "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
-
-
-
@@ -10,9 +10,14 @@ create,fromBinary, toBinary, } from "@bufbuild/protobuf"; import { type Transport, type UnaryResponse } from "@connectrpc/connect"; import { type Decorator } from "@storybook/react"; import { ProtoRPCProvider, type ProtoRPC } from "../../src/lib.ts"; import { ConnectTransportProvider, ProtoRPCProvider, type ProtoRPC, } from "../../src/lib.ts"; export interface MethodMock { service: string;
-
@@ -51,6 +56,34 @@ };} export function withMockedBackend<Args>(mocks: readonly MethodMock[]): Decorator<Args> { const transport: Transport = { async unary(method, _signal, _timeoutMs, _header, input) { for (const mock of mocks) { if (mock.service === method.parent.typeName && mock.method === method.name) { const req = toBinary(method.input, create(method.input, input)); return { method, service: method.parent, header: new Headers(), stream: false, message: fromBinary(method.output, await mock.handler(req)), trailer: new Headers(), } satisfies UnaryResponse<typeof method.input, typeof method.output>; } } throw new Error( `[withMockedBackend] No mock set to "${method.parent.typeName}.${method}"`, ); }, async stream(method, _signal, _timeoutMs, _header, _stream) { throw new Error( `[withMockedBackend] Streaming RPC is not supported ("${method.parent.typeName}.${method}")`, ); }, }; const rpc = { async send(service, method, data) { for (const mock of mocks) {
-
@@ -65,12 +98,14 @@ } satisfies ProtoRPC;return function (Story) { return ( <ProtoRPCProvider rpc={rpc} config={{ defaultOptions: { queries: { retry: false } } }} > <Story /> </ProtoRPCProvider> <ConnectTransportProvider transport={transport}> <ProtoRPCProvider rpc={rpc} config={{ defaultOptions: { queries: { retry: false } } }} > <Story /> </ProtoRPCProvider> </ConnectTransportProvider> ); }; }
-
-
-
@@ -86,6 +86,8 @@ "react-dom": "19.x.x"}, "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2",
-
-
-
@@ -0,0 +1,29 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Transport } from "@connectrpc/connect"; import { createContext, type FC, type ReactNode, use } from "react"; const Context = createContext<Transport | null>(null); export interface ConnectTransportProviderProps { children: ReactNode; transport: Transport; } export const ConnectTransportProvider: FC<ConnectTransportProviderProps> = ({ children, transport, }) => { return <Context.Provider value={transport}>{children}</Context.Provider>; }; export function useConnectTransport(): Transport { const transport = use(Context); if (!transport) { throw new Error("`useConnectTransport` called outside `ConnectTransportProvider`"); } return transport; }
-
-
-
@@ -9,4 +9,5 @@ export { ThirdPartyNoticeProvider } from "./components/CopyrightNotice.ts";export type { ThirdPartyNoticeProviderProps } from "./components/CopyrightNotice.ts"; export * from "./contexts/Service.tsx"; export * from "./contexts/Router.tsx"; export * from "./contexts/ConnectTransport.tsx"; export * from "./pages/page.tsx";
-
-
-
@@ -0,0 +1,60 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { mock, type MockOptions, } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; export interface CreateInitialAdminOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.createInitialAdmin.output>["result"], { case: "ok" | undefined } >; } export function CreateInitialAdmin({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: CreateInitialAdminOptions = {}) { return mock( WorkspaceService.method.createInitialAdmin, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: { value: "ws-" + crypto.randomUUID(), }, name: req.name.trim(), displayName: req.displayName.trim() || req.name.trim(), isAdmin: true, loginMethod: { passwordConfigured: true, }, }, }, }; }, options, ); }
-
-
-
@@ -0,0 +1,58 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { CreateInitialAdmin } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { InitialAdminCreation } from "./InitialAdminCreation.tsx"; export default { component: InitialAdminCreation, decorators: [withInmemoryRouter()], } satisfies Meta<typeof InitialAdminCreation>; type Story = StoryObj<typeof InitialAdminCreation>; export const Success: Story = { decorators: [withMockedBackend([CreateInitialAdmin()])], }; export const Slow: Story = { decorators: [withMockedBackend([CreateInitialAdmin({ delayMs: 1_000 })])], }; export const SystemError: Story = { decorators: [withMockedBackend([CreateInitialAdmin({ failureRate: 1 })])], }; export const PasswordExpired: Story = { decorators: [ withMockedBackend([ CreateInitialAdmin({ failureRate: 1, error: { case: "passwordExpired", value: {}, }, }), ]), ], }; export const AuthenticationError: Story = { decorators: [ withMockedBackend([ CreateInitialAdmin({ failureRate: 1, error: { case: "authenticationError", value: {}, }, }), ]), ], };
-
-
-
@@ -0,0 +1,219 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { CheckCircledIcon } from "@radix-ui/react-icons"; import { Button, Flex, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { CreateInitialAdminRequestSchema } from "@yamori/proto/yamori/workspace/v2/create_initial_admin_request_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react"; import { useForm } from "react-hook-form"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import * as FormField from "../../components/FormField.ts"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { Layout } from "./Layout.tsx"; class PasswordExpiredError extends Error { constructor() { super("Setup password is expired"); } } export interface InitialAdminCreationProps { onCreated?(): void; onPasswordExpired?(): void; } export const InitialAdminCreation: FC<InitialAdminCreationProps> = ({ onCreated, onPasswordExpired, }) => { const toast = useToast(); const form = useForm<{ name: string; displayName: string; loginPassword: string; setupPassword: string; }>({ defaultValues: { name: "", displayName: "", loginPassword: "", setupPassword: "", }, mode: "onBlur", }); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const creation = useMutation({ async mutationFn(req: MessageInitShape<typeof CreateInitialAdminRequestSchema>) { const resp = await client.createInitialAdmin(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "passwordExpired") { toast.open({ severity: "info", title: "既に管理者を作成済みです、ログインしてください", dismissible: true, type: "foreground", }); onPasswordExpired?.(); throw new PasswordExpiredError(); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(user) { onCreated?.(); toast.open({ icon: <CheckCircledIcon />, severity: "success", title: `管理者ユーザ「${user.displayName}」を作成しました`, dismissible: true, type: "foreground", }); }, }); return ( <Layout title="初期管理者作成"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit( ({ name, displayName, loginPassword, setupPassword }) => { creation.mutate({ name, displayName, password: loginPassword, initialAdminPassword: setupPassword, }); }, )} > {creation.error ? ( <ManagedErrorCallout error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="作成に失敗しました" /> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="c_name">ユーザ名</FormField.Label> <TextField.Root id="c_name" disabled={creation.isPending} placeholder="hanako_nihon" color={form.formState.errors.name ? "red" : undefined} aria-invalid={!!form.formState.errors.name} autoComplete="username" {...form.register("name", { required: "必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.name?.message}> ログイン時に利用するユーザ名です。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_displayName">表示名</FormField.Label> <TextField.Root id="c_displayName" disabled={creation.isPending} placeholder="日本 花子" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> 他のユーザも閲覧可能な画面上の表示名です。 未指定の場合はユーザ名が設定されます。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_loginPassword"> ログインパスワード </FormField.Label> <TextField.Root id="c_loginPassword" disabled={creation.isPending} color={form.formState.errors.loginPassword ? "red" : undefined} aria-invalid={!!form.formState.errors.loginPassword} type="password" autoComplete="new-password" {...form.register("loginPassword", { required: "必須です", minLength: { value: 8, message: "8文字以上を入力してください", }, })} /> <FormField.Description error={form.formState.errors.loginPassword?.message}> 新たに作成されるユーザが利用するログインパスワードです。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_setupPassword"> 初期管理者作成パスワード </FormField.Label> <TextField.Root id="c_setupPassword" disabled={creation.isPending} color={form.formState.errors.setupPassword ? "red" : undefined} aria-invalid={!!form.formState.errors.setupPassword} type="password" autoComplete="off" {...form.register("setupPassword", { required: "必須です", })} /> <FormField.Description error={form.formState.errors.setupPassword?.message}> 管理者の初回作成用パスワードです。 システム起動時に発行されたパスワードを入力してください。 </FormField.Description> </FormField.Root> <Button loading={creation.isPending}>作成</Button> </form> </Flex> </Layout> ); };
-
-
-
@@ -0,0 +1,17 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Loading } from "./Loading.tsx"; export default { component: Loading, decorators: [withInmemoryRouter()], } satisfies Meta<typeof Loading>; type Story = StoryObj<typeof Loading>; export const Default: Story = {};
-
-
-
@@ -0,0 +1,20 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Flex, Spinner, Text } from "@radix-ui/themes"; import { type FC } from "react"; import { Layout } from "./Layout.tsx"; export const Loading: FC = () => { return ( <Layout title="ログイン"> <Flex align="center" justify="center" pt="7" gap="2"> <Spinner /> <Text size="2" color="gray"> ログイン状態取得中... </Text> </Flex> </Layout> ); };
-