diff --git a/packages/shared/package.json b/packages/shared/package.json index f641b62e..44ab2a98 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,7 +6,8 @@ "description": "Source-of-truth constants shared between client (src, admin) and server packages. / クライアント (src, admin) とサーバ (server/api 等) で共有する定数モジュール。", "exports": { ".": "./src/index.ts", - "./tagCharacterClass": "./src/tagCharacterClass.ts" + "./tagCharacterClass": "./src/tagCharacterClass.ts", + "./freeEmailDomains": "./src/freeEmailDomains.ts" }, "scripts": { "test": "vitest run" diff --git a/packages/shared/src/freeEmailDomains.test.ts b/packages/shared/src/freeEmailDomains.test.ts new file mode 100644 index 00000000..c56546d5 --- /dev/null +++ b/packages/shared/src/freeEmailDomains.test.ts @@ -0,0 +1,55 @@ +/** + * `normalizeDomainInput` の最低限のスペックテスト。トリミング・小文字化・先頭 `@` の + * 除去・空入力/形式不正/フリーメール拒否を固定する。 + * + * Lock down the minimum contract for `normalizeDomainInput`: trimming, + * lower-casing, leading `@` stripping, and rejection of empty/malformed/ + * free-webmail inputs. + */ +import { describe, it, expect } from "vitest"; + +import { normalizeDomainInput } from "./freeEmailDomains.js"; + +describe("normalizeDomainInput", () => { + it("trims, lower-cases, and accepts a plain domain", () => { + const result = normalizeDomainInput(" Example.COM "); + expect(result).toEqual({ ok: true, domain: "example.com" }); + }); + + it("strips a single leading @ from email-style input", () => { + const result = normalizeDomainInput("@example.com"); + expect(result).toEqual({ ok: true, domain: "example.com" }); + }); + + it("flags empty strings as empty", () => { + expect(normalizeDomainInput("")).toEqual({ ok: false, error: { kind: "empty" } }); + expect(normalizeDomainInput(" ")).toEqual({ ok: false, error: { kind: "empty" } }); + expect(normalizeDomainInput(undefined)).toEqual({ ok: false, error: { kind: "empty" } }); + }); + + it("rejects malformed domains", () => { + expect(normalizeDomainInput("not-a-domain")).toEqual({ + ok: false, + error: { kind: "invalid_format" }, + }); + expect(normalizeDomainInput("example.")).toEqual({ + ok: false, + error: { kind: "invalid_format" }, + }); + }); + + it("rejects free webmail providers (gmail, outlook, yahoo, …)", () => { + expect(normalizeDomainInput("gmail.com")).toEqual({ + ok: false, + error: { kind: "free_email", domain: "gmail.com" }, + }); + expect(normalizeDomainInput("@yahoo.co.jp")).toEqual({ + ok: false, + error: { kind: "free_email", domain: "yahoo.co.jp" }, + }); + expect(normalizeDomainInput("OUTLOOK.com")).toEqual({ + ok: false, + error: { kind: "free_email", domain: "outlook.com" }, + }); + }); +}); diff --git a/packages/shared/src/freeEmailDomains.ts b/packages/shared/src/freeEmailDomains.ts new file mode 100644 index 00000000..f5f483df --- /dev/null +++ b/packages/shared/src/freeEmailDomains.ts @@ -0,0 +1,149 @@ +/** + * `note_domain_access` (#663) のドメイン入力で参照する、フリーメール (free-webmail) + * プロバイダの拒否リストとドメイン文字列の正規化ユーティリティ。 + * + * ドメイン招待は社内 OSS / WG など閉じた組織のメールドメインを対象とした機能なので、 + * 誰でも取得できる Gmail / Outlook / Yahoo 等のドメインを許可してしまうと事実上 + * `unlisted` と区別がつかなくなる。本ファイルが「真実の値」となり、`server/api` + * 側が同じ値を二重定義し、`src/lib/freeEmailDomainsSync.test.ts` が CI で同期を担保する。 + * + * Source-of-truth deny-list of free-webmail providers + domain-input + * validation helpers used by the `note_domain_access` flow (#663). Domain + * invitations are intended for closed organisational domains, so allowing + * "anyone can register" providers (Gmail, Outlook, Yahoo, …) would collapse + * the feature into `unlisted`. This file owns the canonical values; the + * `server/api` package duplicates them in its own copy because it lives + * outside the workspace, and `src/lib/freeEmailDomainsSync.test.ts` keeps + * the two in sync via a CI drift detector. + * + * 同期義務 / Sync obligation: + * - 本ファイルを編集したら `server/api/src/lib/freeEmailDomains.ts` も同じ値で更新する。 + * ドリフト検知テストが失敗したらどちらか片側しか更新していないので、もう片方を揃える。 + * - When this file changes, also update + * `server/api/src/lib/freeEmailDomains.ts`. If the drift test fails, only + * one side was edited — sync the other. + */ + +/** + * 拒否対象の無料メールドメイン(小文字・`@` なし)。 + * 追加する際は小文字で、かつ意味的に「個人が誰でも取得できる」サービスに限定する。 + * + * Free-webmail domains we block for domain-scoped access rules. Entries must + * be lower-case and only cover providers where anyone can register an address. + */ +export const FREE_EMAIL_DOMAINS: ReadonlySet = new Set([ + // Google + "gmail.com", + "googlemail.com", + // Microsoft + "outlook.com", + "outlook.jp", + "hotmail.com", + "hotmail.co.jp", + "live.com", + "live.jp", + "msn.com", + // Yahoo + "yahoo.com", + "yahoo.co.jp", + "ymail.com", + // Apple + "icloud.com", + "me.com", + "mac.com", + // Other major free webmail + "aol.com", + "proton.me", + "protonmail.com", + "pm.me", + "gmx.com", + "gmx.net", + "mail.com", + "zoho.com", + "yandex.com", + "yandex.ru", + // Japanese carriers / ISP free tiers + "docomo.ne.jp", + "ezweb.ne.jp", + "softbank.ne.jp", + "i.softbank.jp", + "ybb.ne.jp", + "nifty.com", + "so-net.ne.jp", + "biglobe.ne.jp", + "ocn.ne.jp", + // Disposable / throwaway (representative) + "mailinator.com", + "guerrillamail.com", + "10minutemail.com", + "tempmail.com", + "trashmail.com", +]); + +/** + * `DomainValidationError` は {@link normalizeDomainInput} が返すエラーを分類する判別共用体。 + * 呼び出し側は HTTP ステータスやユーザー向けメッセージにマップする。 + * + * Discriminated error kinds returned by {@link normalizeDomainInput}. Callers + * map them to HTTP status codes or user-facing messages. + */ +export type DomainValidationError = + | { kind: "empty" } + | { kind: "invalid_format" } + | { kind: "free_email"; domain: string }; + +/** + * ドメイン入力を正規化した結果。成功時は小文字・`@` 無しのドメイン文字列、 + * 失敗時は {@link DomainValidationError}。 + * + * Result of normalising a domain input: either a lower-cased, `@`-less domain + * string or a {@link DomainValidationError}. + */ +export type DomainValidationResult = + | { ok: true; domain: string } + | { ok: false; error: DomainValidationError }; + +/** + * RFC 1035 ベースのラフなドメイン検証。IDN / punycode は将来対応。 + * `example.com` / `a.b-c.example.jp` など、ラベルは英数字とハイフン、各ラベル + * 1..63 文字、全体 1..253 文字、TLD はアルファベットで 2 文字以上。 + * + * Lightweight RFC 1035 domain check (IDN / punycode left for a later pass). + * Each label is alphanumeric plus hyphen, 1..63 chars; the whole name must be + * 1..253 chars; the TLD must be alphabetic and at least 2 characters. + */ +export const DOMAIN_REGEX = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; + +/** + * ユーザー/API 入力のドメイン文字列を正規化する。 + * + * - 前後空白を除去し小文字化 + * - 先頭 `@` を 1 つだけ除去(`@example.com` 形式の入力を救済) + * - 空文字・形式不正・フリーメールドメインを拒否 + * + * Normalise a raw domain input: + * - trim & lower-case + * - strip a single leading `@` + * - reject empty strings, malformed domains, and free-webmail providers. + * + * @param raw - ユーザーから受け取った生の文字列 / Raw user input + */ +export function normalizeDomainInput(raw: unknown): DomainValidationResult { + if (typeof raw !== "string") { + return { ok: false, error: { kind: "empty" } }; + } + let value = raw.trim().toLowerCase(); + if (value.startsWith("@")) { + value = value.slice(1); + } + if (value.length === 0) { + return { ok: false, error: { kind: "empty" } }; + } + if (!DOMAIN_REGEX.test(value)) { + return { ok: false, error: { kind: "invalid_format" } }; + } + if (FREE_EMAIL_DOMAINS.has(value)) { + return { ok: false, error: { kind: "free_email", domain: value } }; + } + return { ok: true, domain: value }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a6121744..0c589a88 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,3 +7,10 @@ * Node-only APIs so the package stays universally importable. */ export { TAG_NAME_CHAR_CLASS } from "./tagCharacterClass.js"; +export { + DOMAIN_REGEX, + FREE_EMAIL_DOMAINS, + normalizeDomainInput, + type DomainValidationError, + type DomainValidationResult, +} from "./freeEmailDomains.js"; diff --git a/server/api/src/lib/freeEmailDomains.ts b/server/api/src/lib/freeEmailDomains.ts index 38dc02f4..36e279ae 100644 --- a/server/api/src/lib/freeEmailDomains.ts +++ b/server/api/src/lib/freeEmailDomains.ts @@ -8,6 +8,15 @@ * ハードコードされた拒否リストで代表的なものだけ弾き、将来は DNS TXT による * 所有権検証 (`verifiedAt`) に進化させる前提の最低限のガード。 * + * 同期義務 / Sync obligation: + * - `packages/shared/src/freeEmailDomains.ts` が真実の値。`server/api` は + * ワークスペース外なので `@zedi/shared` を直接 import できず、本ファイルで + * 同じ値を二重定義している。`src/lib/freeEmailDomainsSync.test.ts` が + * 両者の `FREE_EMAIL_DOMAINS` / `DOMAIN_REGEX` の一致を CI で担保する。 + * - 本ファイルを編集したら `packages/shared/src/freeEmailDomains.ts` も + * 同じ値で更新すること。ドリフト検知テストが失敗したら片側しか更新して + * いないので、もう片方を揃える。 + * * Deny-list of free-webmail providers, plus helpers to normalise and validate * domain inputs for `note_domain_access` (issue #663). * @@ -16,6 +25,12 @@ * collapse this feature into `unlisted`, since anyone can mint an address at * those hosts. This module hard-codes the most common ones as a v1 safeguard; * a future v2 will add DNS-TXT ownership verification via `verifiedAt`. + * + * Sync obligation: the canonical copy lives in + * `packages/shared/src/freeEmailDomains.ts`. `server/api` cannot import + * `@zedi/shared` because it lives outside the workspace, so this file + * duplicates the values; `src/lib/freeEmailDomainsSync.test.ts` enforces + * equality between the two sides in CI. Update both files together. */ /** diff --git a/src/hooks/useDomainAccess.ts b/src/hooks/useDomainAccess.ts new file mode 100644 index 00000000..ce7d4de3 --- /dev/null +++ b/src/hooks/useDomainAccess.ts @@ -0,0 +1,63 @@ +/** + * React Query hooks for the note domain-access flow (epic #657 / issue #663). + * ノートのドメイン招待 (note_domain_access) フローの React Query フック。 + */ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { createApiClient } from "@/lib/api"; +import type { CreateDomainAccessBody, DomainAccessRow } from "@/lib/api/types"; + +/** + * Query key factory for domain-access queries. + * ドメイン招待系クエリのキー工場。 + */ +export const domainAccessKeys = { + all: ["domain-access"] as const, + listByNote: (noteId: string) => [...domainAccessKeys.all, "note", noteId] as const, +}; + +/** + * List domain-access rules for a note (owner / editor). + * ノートのドメインルール一覧を取得する(owner / editor)。 + */ +export function useDomainAccessForNote(noteId: string, enabled = true) { + const api = createApiClient(); + return useQuery({ + queryKey: domainAccessKeys.listByNote(noteId), + queryFn: () => api.listDomainAccess(noteId), + enabled: enabled && !!noteId, + }); +} + +/** + * Create a new domain-access rule (owner only). Free-email providers are + * rejected by the server with HTTP 400. + * ドメインルールを追加する(オーナーのみ)。フリーメール (gmail.com 等) は + * サーバーが 400 で拒否する。 + */ +export function useCreateDomainAccess(noteId: string) { + const api = createApiClient(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body) => api.createDomainAccess(noteId, body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: domainAccessKeys.listByNote(noteId) }); + }, + }); +} + +/** + * Delete an existing domain-access rule (owner only). The effect is immediate; + * any user who was relying on this rule loses access on their next request. + * ドメインルールを削除する(オーナーのみ)。削除は即座に反映され、その + * ドメインに依存していたアクセスは次回リクエストから失効する。 + */ +export function useDeleteDomainAccess(noteId: string) { + const api = createApiClient(); + const qc = useQueryClient(); + return useMutation<{ removed: true; id: string }, Error, { accessId: string }>({ + mutationFn: ({ accessId }) => api.deleteDomainAccess(noteId, accessId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: domainAccessKeys.listByNote(noteId) }); + }, + }); +} diff --git a/src/i18n/locales/en/notes.json b/src/i18n/locales/en/notes.json index d7c4ff5c..b2af7449 100644 --- a/src/i18n/locales/en/notes.json +++ b/src/i18n/locales/en/notes.json @@ -186,5 +186,28 @@ "shareVisibilityDescriptionPublic": "Anyone can discover and view this note.", "shareVisibilityDescriptionRestricted": "Only invited members can view; the link alone does not grant access.", "shareUnlistedUrlHint": "Copy this link to share with viewers:", - "shareSaveChanges": "Save changes" + "shareSaveChanges": "Save changes", + "domainTabHeading": "Domain access", + "domainTabDescription": "Anyone signed in with an email at one of these domains automatically gets access. Free webmail providers (gmail.com, outlook.com, …) are not accepted.", + "domainTabAddHeading": "Add domain rule", + "domainPlaceholder": "example.com", + "domainTabAdd": "Add", + "domainTabRemove": "Remove", + "domainTabRemoveAria": "Remove domain {{domain}}", + "domainTabRoleViewer": "Viewer", + "domainTabRoleEditor": "Editor", + "domainTabNoRules": "No domain rules yet.", + "domainTabLoading": "Loading domain rules…", + "domainTabLoadFailed": "Failed to load domain rules. Please try again later.", + "domainTabRuleSummary": "Anyone with an email at {{domain}} can join as {{role}}.", + "domainTabUnverifiedBadge": "Unverified", + "domainTabUnverifiedHint": "Domain ownership is not yet verified. DNS-TXT verification will arrive in a future release.", + "domainTabEditorWarning": "This rule grants edit permission to everyone at this domain. Only do this for closed organisations.", + "domainTabCreated": "Domain rule added", + "domainTabCreateFailed": "Failed to add domain rule", + "domainTabCreateFailedFreeEmail": "{{domain}} is a free webmail provider and cannot be used as a domain rule.", + "domainTabCreateFailedInvalid": "The domain format is invalid.", + "domainTabCreateFailedEmpty": "Enter a domain.", + "domainTabRemoved": "Domain rule removed", + "domainTabRemoveFailed": "Failed to remove domain rule" } diff --git a/src/i18n/locales/ja/notes.json b/src/i18n/locales/ja/notes.json index 7983541e..ee967930 100644 --- a/src/i18n/locales/ja/notes.json +++ b/src/i18n/locales/ja/notes.json @@ -186,5 +186,28 @@ "shareVisibilityDescriptionPublic": "誰でもこのノートを発見・閲覧できます。", "shareVisibilityDescriptionRestricted": "招待されたメンバーのみ閲覧できます。URL を知っていてもアクセス権は付与されません。", "shareUnlistedUrlHint": "このリンクをコピーして共有できます:", - "shareSaveChanges": "変更を保存" + "shareSaveChanges": "変更を保存", + "domainTabHeading": "ドメイン招待", + "domainTabDescription": "指定したドメインのメールでサインインしたユーザーは自動でアクセスできます。フリーメール(gmail.com / outlook.com など)は登録できません。", + "domainTabAddHeading": "ドメインルールを追加", + "domainPlaceholder": "example.com", + "domainTabAdd": "追加", + "domainTabRemove": "削除", + "domainTabRemoveAria": "{{domain}} を削除", + "domainTabRoleViewer": "閲覧者", + "domainTabRoleEditor": "編集者", + "domainTabNoRules": "ドメインルールはまだありません。", + "domainTabLoading": "ドメインルールを読み込み中…", + "domainTabLoadFailed": "ドメインルールを取得できませんでした。しばらくしてから再度お試しください。", + "domainTabRuleSummary": "{{domain}} のメールを持つユーザーは {{role}} として参加できます。", + "domainTabUnverifiedBadge": "未検証", + "domainTabUnverifiedHint": "ドメインの所有権は未検証です。DNS TXT による検証は今後のリリースで追加予定です。", + "domainTabEditorWarning": "このルールはドメイン全員に編集権限を渡します。閉じた組織内でのみ利用してください。", + "domainTabCreated": "ドメインルールを追加しました", + "domainTabCreateFailed": "ドメインルールの追加に失敗しました", + "domainTabCreateFailedFreeEmail": "{{domain}} はフリーメールのため、ドメインルールに登録できません。", + "domainTabCreateFailedInvalid": "ドメインの形式が正しくありません。", + "domainTabCreateFailedEmpty": "ドメインを入力してください。", + "domainTabRemoved": "ドメインルールを削除しました", + "domainTabRemoveFailed": "ドメインルールの削除に失敗しました" } diff --git a/src/lib/api/apiClient.ts b/src/lib/api/apiClient.ts index a1cf2041..6971c073 100644 --- a/src/lib/api/apiClient.ts +++ b/src/lib/api/apiClient.ts @@ -29,6 +29,8 @@ import type { InviteLinkRedeemResponse, CreateInviteLinkBody, InviteLinkRow, + DomainAccessRow, + CreateDomainAccessBody, } from "./types"; export type { NoteListItem }; @@ -576,6 +578,51 @@ export function createApiClient(options?: Partial) { ); }, + // ── Domain access (epic #657 / issue #663) ───────────────────────────── + + /** + * GET /api/notes/:noteId/domain-access — ドメインルール一覧(owner / editor)。 + * List domain-access rules for a note (owner or editor). + */ + async listDomainAccess(noteId: string): Promise { + return req( + "GET", + `/api/notes/${encodeURIComponent(noteId)}/domain-access`, + ); + }, + + /** + * POST /api/notes/:noteId/domain-access — ドメインルールを追加(オーナー)。 + * Create a domain-access rule (owner only). Server rejects free-email + * providers (gmail.com etc.) with HTTP 400. + */ + async createDomainAccess( + noteId: string, + body: CreateDomainAccessBody, + ): Promise { + return req( + "POST", + `/api/notes/${encodeURIComponent(noteId)}/domain-access`, + { body }, + ); + }, + + /** + * DELETE /api/notes/:noteId/domain-access/:id — ドメインルールを削除(オーナー)。 + * 削除直後にそのドメインからのアクセスは失効する(キャッシュなし)。 + * Delete a domain-access rule (owner only). Effect is immediate — callers + * who relied on this rule lose access on their next request. + */ + async deleteDomainAccess( + noteId: string, + accessId: string, + ): Promise<{ removed: true; id: string }> { + return req<{ removed: true; id: string }>( + "DELETE", + `/api/notes/${encodeURIComponent(noteId)}/domain-access/${encodeURIComponent(accessId)}`, + ); + }, + // ── Onboarding ─────────────────────────────────────────────────────── /** diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 9c130cfa..b75078d0 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -446,3 +446,31 @@ export interface InviteLinkRow { label: string | null; created_at: string; } + +// ── Domain access (epic #657 / issue #663) ──────────────────────────────── + +/** + * `note_domain_access` 行の API 表現。サーバーが snake_case で返す。 + * API representation of a `note_domain_access` row (snake_case from server). + */ +export interface DomainAccessRow { + id: string; + note_id: string; + domain: string; + role: "viewer" | "editor"; + created_by_user_id: string; + /** v1 では常に null(v2 で DNS-TXT 検証時に設定)/ Always null in v1; reserved for DNS-TXT verification in v2. */ + verified_at: string | null; + created_at: string; +} + +/** + * `POST /api/notes/:noteId/domain-access` のリクエストボディ。 + * Request body for creating a domain-access rule. + */ +export interface CreateDomainAccessBody { + /** 小文字、`@` なし。サーバーが正規化・フリーメール拒否を行う / Lowercased, no leading `@`; server normalises and rejects free-email providers. */ + domain: string; + /** ロール(既定: `viewer`)/ Role (default: `viewer`). */ + role?: "viewer" | "editor"; +} diff --git a/src/lib/domainValidation.ts b/src/lib/domainValidation.ts new file mode 100644 index 00000000..3f25ec72 --- /dev/null +++ b/src/lib/domainValidation.ts @@ -0,0 +1,19 @@ +/** + * クライアント側からのドメイン入力検証 (`note_domain_access`, issue #663) の入口。 + * 真実の値は `@zedi/shared/freeEmailDomains` に集約されており、サーバ側 ( + * `server/api/src/lib/freeEmailDomains.ts`) にも同じ値が二重定義されている。 + * 同期は `src/lib/freeEmailDomainsSync.test.ts` のドリフト検知テストで担保する。 + * + * Client-side entry point for domain-input validation used by the + * `note_domain_access` flow (issue #663). The canonical values live in + * `@zedi/shared/freeEmailDomains`; `server/api` keeps a duplicate copy + * because it lives outside the workspace, and + * `src/lib/freeEmailDomainsSync.test.ts` enforces equality in CI. + */ +export { + DOMAIN_REGEX, + FREE_EMAIL_DOMAINS, + normalizeDomainInput, + type DomainValidationError, + type DomainValidationResult, +} from "@zedi/shared/freeEmailDomains"; diff --git a/src/lib/freeEmailDomainsSync.test.ts b/src/lib/freeEmailDomainsSync.test.ts new file mode 100644 index 00000000..13308586 --- /dev/null +++ b/src/lib/freeEmailDomainsSync.test.ts @@ -0,0 +1,82 @@ +/** + * `@zedi/shared/freeEmailDomains` と、`server/api` 側で同じ値を二重定義している + * `server/api/src/lib/freeEmailDomains.ts` がドリフトしていないことを CI で + * 保証するテスト。`server/api` はルートの Bun workspace から意図的に外れて + * いるため `@zedi/shared` を直接 import できないので、本テストがサーバ側の + * ファイルを `fs.readFileSync` で読み、`FREE_EMAIL_DOMAINS` セットと + * `DOMAIN_REGEX` パターンが両側で完全に一致することを検証する。 + * + * Drift detector that fails CI when `@zedi/shared`'s `FREE_EMAIL_DOMAINS` / + * `DOMAIN_REGEX` and the server-side duplicates in + * `server/api/src/lib/freeEmailDomains.ts` disagree. `server/api` lives + * outside the Bun workspace (Railway uses `server/api/` as the build + * context), so it cannot import `@zedi/shared`. This test reads the server + * file from disk and compares the canonical values byte-for-byte. + */ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, it, expect } from "vitest"; +import { DOMAIN_REGEX, FREE_EMAIL_DOMAINS } from "@zedi/shared/freeEmailDomains"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * サーバーソース内の `FREE_EMAIL_DOMAINS = new Set([...])` 配列リテラルから + * 各エントリの文字列だけを順に取り出す。文字列リテラルはダブルクォート前提 + * (プロジェクト全体の Prettier 設定)。テンプレートリテラル化したい場合は + * 本テストも拡張する。 + * + * Extract the string entries inside `FREE_EMAIL_DOMAINS = new Set([...])` + * from the server source. Assumes double-quoted string literals (matches + * Prettier defaults); extend if the file ever switches to template literals. + */ +function parseServerDomainSet(source: string): string[] { + const arrayMatch = source.match( + /export const FREE_EMAIL_DOMAINS\b[^=]*=\s*new Set(?:<[^>]*>)?\(\s*\[([\s\S]*?)\]\s*\)/, + ); + expect( + arrayMatch, + "FREE_EMAIL_DOMAINS export not found in server/api/src/lib/freeEmailDomains.ts", + ).not.toBeNull(); + if (!arrayMatch) return []; + const body = arrayMatch[1]; + const entries: string[] = []; + const stringLiteral = /"((?:[^"\\]|\\.)*)"/g; + let m: RegExpExecArray | null; + while ((m = stringLiteral.exec(body)) !== null) { + entries.push(JSON.parse(`"${m[1]}"`) as string); + } + return entries; +} + +/** + * サーバ側 `DOMAIN_REGEX` のソース文字列を取り出す。`new RegExp(...)` 形式は + * 想定していない(プロジェクトのコードは正規表現リテラルで書かれている)。 + * + * Extract the source of the server-side `DOMAIN_REGEX` literal. We assume the + * regex-literal form used everywhere else in the codebase, not `new RegExp(...)`. + */ +function parseServerDomainRegex(source: string): string | null { + const match = source.match(/const DOMAIN_REGEX\s*=\s*\/(.+?)\/[a-z]*\s*;/); + return match?.[1] ?? null; +} + +describe("FREE_EMAIL_DOMAINS / DOMAIN_REGEX sync between @zedi/shared and server/api", () => { + const serverFilePath = resolve(__dirname, "../../server/api/src/lib/freeEmailDomains.ts"); + const source = readFileSync(serverFilePath, "utf8"); + + it("server/api/src/lib/freeEmailDomains.ts mirrors the shared FREE_EMAIL_DOMAINS set", () => { + const serverEntries = parseServerDomainSet(source); + expect(new Set(serverEntries)).toEqual(FREE_EMAIL_DOMAINS); + // 二重登録が無いこと(パース上のドリフトを防ぐ)/ guard against duplicates. + expect(serverEntries).toHaveLength(new Set(serverEntries).size); + }); + + it("server/api/src/lib/freeEmailDomains.ts mirrors the shared DOMAIN_REGEX pattern", () => { + const serverPattern = parseServerDomainRegex(source); + expect(serverPattern, "DOMAIN_REGEX literal not found in server file").not.toBeNull(); + expect(serverPattern).toBe(DOMAIN_REGEX.source); + }); +}); diff --git a/src/pages/NoteView/ShareModal/NoteShareModal.test.tsx b/src/pages/NoteView/ShareModal/NoteShareModal.test.tsx index 3514c6c4..e471a134 100644 --- a/src/pages/NoteView/ShareModal/NoteShareModal.test.tsx +++ b/src/pages/NoteView/ShareModal/NoteShareModal.test.tsx @@ -3,8 +3,8 @@ * Tests for the NoteShareModal tab structure. * * 観点 / Coverage: - * - 基本タブ (メンバー / リンク / 公開設定) が表示される - * - ドメインタブは showDomainsTab が true のときのみ表示される + * - 基本タブ (メンバー / リンク / ドメイン / 公開設定) が表示される + * - ドメインタブは showDomainsTab=false で隠せる * - モーダルが閉じているときは hidden 状態 * - 公開設定タブで visibility が unlisted のとき共有 URL フィールドが表示される */ @@ -52,6 +52,12 @@ vi.mock("@/hooks/useInviteLinks", () => ({ useRevokeInviteLink: () => ({ mutateAsync: vi.fn(), isPending: false }), })); +vi.mock("@/hooks/useDomainAccess", () => ({ + useDomainAccessForNote: () => ({ data: [], isLoading: false }), + useCreateDomainAccess: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteDomainAccess: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + const baseNote: Note = { id: "note-1", ownerUserId: "user-1", @@ -81,17 +87,17 @@ describe("NoteShareModal", () => { vi.clearAllMocks(); }); - it("renders members, links, and visibility tabs by default", () => { + it("renders members, links, domains, and visibility tabs by default", () => { renderModal(); expect(screen.getByRole("tab", { name: "notes.shareTabMembers" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "notes.shareTabLinks" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "notes.shareTabDomains" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "notes.shareTabVisibility" })).toBeInTheDocument(); - expect(screen.queryByRole("tab", { name: "notes.shareTabDomains" })).not.toBeInTheDocument(); }); - it("shows domains tab when showDomainsTab is true", () => { - renderModal({ showDomainsTab: true }); - expect(screen.getByRole("tab", { name: "notes.shareTabDomains" })).toBeInTheDocument(); + it("hides the domains tab when showDomainsTab is false", () => { + renderModal({ showDomainsTab: false }); + expect(screen.queryByRole("tab", { name: "notes.shareTabDomains" })).not.toBeInTheDocument(); }); it("renders nothing visible when open is false", () => { @@ -122,4 +128,35 @@ describe("NoteShareModal", () => { await user.click(screen.getByRole("tab", { name: "notes.shareTabVisibility" })); expect(screen.queryByLabelText("notes.shareLink")).not.toBeInTheDocument(); }); + + it("falls back to the members tab when showDomainsTab flips to false on the active tab", async () => { + const user = userEvent.setup(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender } = render( + + + {}} note={baseNote} showDomainsTab /> + + , + ); + await user.click(screen.getByRole("tab", { name: "notes.shareTabDomains" })); + expect(screen.getByRole("tab", { name: "notes.shareTabDomains" })).toHaveAttribute( + "data-state", + "active", + ); + + rerender( + + + {}} note={baseNote} showDomainsTab={false} /> + + , + ); + + expect(screen.queryByRole("tab", { name: "notes.shareTabDomains" })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "notes.shareTabMembers" })).toHaveAttribute( + "data-state", + "active", + ); + }); }); diff --git a/src/pages/NoteView/ShareModal/NoteShareModal.tsx b/src/pages/NoteView/ShareModal/NoteShareModal.tsx index e3d73d21..eb05c102 100644 --- a/src/pages/NoteView/ShareModal/NoteShareModal.tsx +++ b/src/pages/NoteView/ShareModal/NoteShareModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Dialog, @@ -13,6 +13,7 @@ import { } from "@zedi/ui"; import type { Note } from "@/types/note"; import { NoteInviteLinksSection } from "@/pages/NoteMembers/NoteInviteLinksSection"; +import { ShareModalDomainTab } from "./ShareModalDomainTab"; import { ShareModalMembersTab } from "./ShareModalMembersTab"; import { ShareModalVisibilityTab } from "./ShareModalVisibilityTab"; @@ -25,17 +26,20 @@ export interface NoteShareModalProps { onOpenChange: (open: boolean) => void; note: Note; /** - * Phase 6 (#663) のドメイン許可タブを表示するかどうか。未実装のときは `false` を指定してタブを隠す。 - * Whether to show the Phase 6 (#663) domain-allowlist tab. Pass `false` (the - * default) to hide it until that phase ships. + * ドメイン招待タブ (Phase 6 / #663) を表示するか。既定で表示する。 + * 必要に応じて `false` を渡せば非表示にできる(テスト用途・特殊フロー想定)。 + * + * Whether to show the domain-access tab (Phase 6 / issue #663). Defaults to + * `true` now that the feature has shipped; pass `false` to hide it for + * specific flows (e.g. tests, edge-case UIs). */ showDomainsTab?: boolean; } /** - * ノート共有モーダル。メンバー招待・共有リンク・公開設定を 1 つのダイアログに集約する。 - * Consolidated share modal for a note: members, share links, and visibility - * (domains tab is reserved for Phase 6 and hidden by default). + * ノート共有モーダル。メンバー招待・共有リンク・ドメイン招待・公開設定を 1 つのダイアログに集約する。 + * Consolidated share modal for a note: members, share links, domain access, + * and visibility. * * このモーダルはオーナー向け UI のみサポートする。エディタ向けの読み取り専用 * 表示は別 Issue のフォローアップで追加する。 @@ -47,11 +51,22 @@ export function NoteShareModal({ open, onOpenChange, note, - showDomainsTab = false, + showDomainsTab = true, }: NoteShareModalProps) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("members"); + // ドメインタブを表示中に `showDomainsTab` が false に切り替わると、Tabs が + // 存在しない値を保持してパネルが空になる。先頭の members タブへフォールバック。 + // If `showDomainsTab` flips to false while the domains tab is active, the + // Tabs control would hold a non-existent value; fall back to the first tab. + useEffect(() => { + if (!showDomainsTab && activeTab === "domains") { + // eslint-disable-next-line react-hooks/set-state-in-effect -- guard for vanished tab + setActiveTab("members"); + } + }, [showDomainsTab, activeTab]); + return ( @@ -81,6 +96,12 @@ export function NoteShareModal({ + {showDomainsTab ? ( + + + + ) : null} + diff --git a/src/pages/NoteView/ShareModal/ShareModalDomainTab.test.tsx b/src/pages/NoteView/ShareModal/ShareModalDomainTab.test.tsx new file mode 100644 index 00000000..021db6a2 --- /dev/null +++ b/src/pages/NoteView/ShareModal/ShareModalDomainTab.test.tsx @@ -0,0 +1,193 @@ +/** + * ShareModalDomainTab のテスト。 + * Tests for the share-modal domain tab (issue #663). + * + * 観点 / Coverage: + * - 既存ルールが viewer / editor バッジ + 未検証バッジ付きで一覧表示される + * - 空のときは「ルールがありません」を表示 + * - フリーメール (gmail.com 等) を入力するとインライン警告 + ボタンが disable + * - 形式不正の入力もインライン警告 + * - viewer 追加は確認ダイアログなしで送信される + * - editor 追加は確認ダイアログを挟んで送信される + * - 削除ボタンで accessId が渡る + */ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ShareModalDomainTab } from "./ShareModalDomainTab"; +import type { DomainAccessRow } from "@/lib/api/types"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts + ? `${key}(${Object.entries(opts) + .map(([k, v]) => `${k}=${String(v)}`) + .join(",")})` + : key, + i18n: { language: "ja" }, + }), +})); + +const toastFn = vi.fn(); +vi.mock("@zedi/ui", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useToast: () => ({ toast: toastFn }), + }; +}); + +const useDomainAccessForNote = vi.fn(); +const createMutateAsync = vi.fn(); +const deleteMutateAsync = vi.fn(); +const useCreateDomainAccess = vi.fn(); +const useDeleteDomainAccess = vi.fn(); + +vi.mock("@/hooks/useDomainAccess", () => ({ + useDomainAccessForNote: (...args: unknown[]) => useDomainAccessForNote(...args), + useCreateDomainAccess: (...args: unknown[]) => useCreateDomainAccess(...args), + useDeleteDomainAccess: (...args: unknown[]) => useDeleteDomainAccess(...args), +})); + +const NOTE_ID = "note-1"; + +function row(overrides: Partial = {}): DomainAccessRow { + return { + id: "rule-1", + note_id: NOTE_ID, + domain: "example.com", + role: "viewer", + created_by_user_id: "user-1", + verified_at: null, + created_at: "2026-04-27T00:00:00.000Z", + ...overrides, + }; +} + +function renderTab( + props: { rules?: DomainAccessRow[]; isLoading?: boolean; isError?: boolean } = {}, +) { + useDomainAccessForNote.mockReturnValue({ + data: props.rules ?? [], + isLoading: props.isLoading ?? false, + isError: props.isError ?? false, + }); + useCreateDomainAccess.mockReturnValue({ + mutateAsync: createMutateAsync, + isPending: false, + }); + useDeleteDomainAccess.mockReturnValue({ + mutateAsync: deleteMutateAsync, + isPending: false, + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +// Radix UI の Select は PointerEvent / scrollIntoView を直接触るが jsdom には +// 実装がないので、最低限のスタブを当てる。 +// Radix Select touches PointerEvent / scrollIntoView APIs that jsdom does not +// implement; stub them so the dropdown can open under test. +beforeEach(() => { + Object.assign(Element.prototype, { + hasPointerCapture: () => false, + setPointerCapture: () => undefined, + releasePointerCapture: () => undefined, + scrollIntoView: () => undefined, + }); +}); + +describe("ShareModalDomainTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + createMutateAsync.mockResolvedValue(row()); + deleteMutateAsync.mockResolvedValue({ removed: true, id: "rule-1" }); + }); + + it("shows the empty state when there are no rules", () => { + renderTab(); + expect(screen.getByText("notes.domainTabNoRules")).toBeInTheDocument(); + }); + + it("shows a load-failure message instead of the empty state when the query errors", () => { + renderTab({ isError: true, rules: undefined }); + expect(screen.getByText("notes.domainTabLoadFailed")).toBeInTheDocument(); + expect(screen.queryByText("notes.domainTabNoRules")).not.toBeInTheDocument(); + }); + + it("renders rules with role badge and unverified badge", () => { + renderTab({ + rules: [ + row({ id: "r1", domain: "example.com", role: "viewer" }), + row({ id: "r2", domain: "acme.test", role: "editor" }), + ], + }); + expect(screen.getByText("example.com")).toBeInTheDocument(); + expect(screen.getByText("acme.test")).toBeInTheDocument(); + // Both rows are unverified in v1. + const unverified = screen.getAllByText("notes.domainTabUnverifiedBadge"); + expect(unverified).toHaveLength(2); + }); + + it("disables the add button until a valid non-free domain is typed", async () => { + const user = userEvent.setup(); + renderTab(); + const addButton = screen.getByRole("button", { name: "notes.domainTabAdd" }); + expect(addButton).toBeDisabled(); + + const input = screen.getByPlaceholderText("notes.domainPlaceholder"); + await user.type(input, "gmail.com"); + expect(addButton).toBeDisabled(); + expect(screen.getByText(/notes\.domainTabCreateFailedFreeEmail/)).toBeInTheDocument(); + + await user.clear(input); + await user.type(input, "example.com"); + expect(addButton).not.toBeDisabled(); + }); + + it("submits a viewer rule without confirmation", async () => { + const user = userEvent.setup(); + renderTab(); + const input = screen.getByPlaceholderText("notes.domainPlaceholder"); + await user.type(input, "example.com"); + await user.click(screen.getByRole("button", { name: "notes.domainTabAdd" })); + expect(createMutateAsync).toHaveBeenCalledWith({ domain: "example.com", role: "viewer" }); + }); + + it("requires confirmation before submitting an editor rule", async () => { + const user = userEvent.setup(); + renderTab(); + const input = screen.getByPlaceholderText("notes.domainPlaceholder"); + await user.type(input, "example.com"); + + // Switch the role select to editor by clicking it open and choosing editor. + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByRole("option", { name: "notes.domainTabRoleEditor" })); + + await user.click(screen.getByRole("button", { name: "notes.domainTabAdd" })); + // Confirmation dialog appears and we have not yet hit the API. + expect(createMutateAsync).not.toHaveBeenCalled(); + const dialog = await screen.findByRole("alertdialog"); + expect(within(dialog).getByText("notes.domainTabEditorWarning")).toBeInTheDocument(); + + await user.click(within(dialog).getByRole("button", { name: "common.confirm" })); + expect(createMutateAsync).toHaveBeenCalledWith({ domain: "example.com", role: "editor" }); + }); + + it("calls delete with the rule's id when remove is clicked", async () => { + const user = userEvent.setup(); + renderTab({ rules: [row({ id: "rule-42", domain: "example.com" })] }); + const removeButton = screen.getByRole("button", { + name: "notes.domainTabRemoveAria(domain=example.com)", + }); + await user.click(removeButton); + expect(deleteMutateAsync).toHaveBeenCalledWith({ accessId: "rule-42" }); + }); +}); diff --git a/src/pages/NoteView/ShareModal/ShareModalDomainTab.tsx b/src/pages/NoteView/ShareModal/ShareModalDomainTab.tsx new file mode 100644 index 00000000..8bb93b47 --- /dev/null +++ b/src/pages/NoteView/ShareModal/ShareModalDomainTab.tsx @@ -0,0 +1,336 @@ +/** + * 共有モーダルのドメイン招待タブ (Phase 6 / issue #663)。 + * Domain-access tab inside the share modal — adds, lists, and removes domain + * rules backed by `note_domain_access`. Free-webmail providers are pre-checked + * client-side and ultimately rejected by the server. + * + * 編集者向けロールでドメインを追加するときは、編集権限が広く渡るリスクが + * あるため確認ダイアログを挟む。`verifiedAt` は v1 では常に null なので + * 全ての行で「未検証」バッジを出して注意を促す。 + * + * Adding an `editor` rule pops a confirmation dialog because it grants edit + * access to everyone at that domain. `verifiedAt` is always null in v1, so + * each row carries an "unverified" badge until DNS-TXT verification ships. + */ +import { useMemo, useState } from "react"; +import { Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + Button, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + useToast, +} from "@zedi/ui"; +import { + useCreateDomainAccess, + useDeleteDomainAccess, + useDomainAccessForNote, +} from "@/hooks/useDomainAccess"; +import { ApiError } from "@/lib/api"; +import { normalizeDomainInput } from "@/lib/domainValidation"; +import type { DomainAccessRow } from "@/lib/api/types"; + +/** + * ドメインルール選択肢のロール。 + * Roles selectable when adding a domain rule. + */ +type DomainRole = "viewer" | "editor"; + +/** + * ドメインタブの Props。 + * Props for the domain tab. + */ +export interface ShareModalDomainTabProps { + noteId: string; + /** + * モーダルが開いているか。閉じている間は React Query を発火させないために + * `enabled` として下流に渡す。 + * Whether the parent modal is open. Forwarded to React Query as `enabled` + * so we don't fetch while the modal is hidden. + */ + enabled: boolean; +} + +/** + * 入力エラーキー判定の戻り値型。`null` ならエラーなし。 + * Result of computing the inline-error label for the domain input. + */ +type InputError = null | { kind: "invalid_format" } | { kind: "free_email"; domain: string }; + +/** + * 入力欄の状態 + ミューテーションを 1 つの interface にまとめて引数の数を抑える。 + * Bundle of state passed into the inner add-form to keep the prop list short. + */ +interface AddFormProps { + domainInput: string; + setDomainInput: (v: string) => void; + roleInput: DomainRole; + setRoleInput: (v: DomainRole) => void; + onAdd: () => void; + isPending: boolean; + isValid: boolean; + inputError: InputError; +} + +/** + * 入力フォーム部分。`AlertDialog` の確認ロジックは親側に残しているので、ここは + * 値の表示と「追加」ボタンの click ハンドラを呼ぶだけのプレゼンテーション層。 + * + * Add-form section. The confirmation flow lives in the parent; this component + * is purely presentational and just calls `onAdd` when the button is clicked. + */ +function DomainAccessAddForm({ + domainInput, + setDomainInput, + roleInput, + setRoleInput, + onAdd, + isPending, + isValid, + inputError, +}: AddFormProps) { + const { t } = useTranslation(); + const errorMessage = (() => { + if (!inputError) return null; + if (inputError.kind === "invalid_format") return t("notes.domainTabCreateFailedInvalid"); + return t("notes.domainTabCreateFailedFreeEmail", { domain: inputError.domain }); + })(); + return ( +
+

{t("notes.domainTabAddHeading")}

+
+
+ setDomainInput(event.target.value)} + placeholder={t("notes.domainPlaceholder")} + aria-invalid={errorMessage ? true : undefined} + aria-describedby={errorMessage ? "domain-input-error" : undefined} + /> + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ + +
+
+ ); +} + +/** + * 一覧行。ロールバッジ・未検証バッジ・削除ボタンを 1 行で描画する。 + * Single row inside the rule list — role + unverified badge + remove button. + */ +function DomainAccessRuleItem({ + rule, + onRemove, + removePending, +}: { + rule: DomainAccessRow; + onRemove: (rule: DomainAccessRow) => void; + removePending: boolean; +}) { + const { t } = useTranslation(); + const roleLabel = + rule.role === "editor" ? t("notes.domainTabRoleEditor") : t("notes.domainTabRoleViewer"); + return ( +
+
+
+ {rule.domain} + {roleLabel} + {rule.verified_at === null ? ( + + {t("notes.domainTabUnverifiedBadge")} + + ) : null} +
+

+ {t("notes.domainTabRuleSummary", { domain: rule.domain, role: roleLabel })} +

+
+ +
+ ); +} + +/** + * 共有モーダルのドメインタブ。ドメインルールの追加・一覧・削除を扱う。 + * Domain tab — handles add / list / remove for domain-access rules. + */ +export function ShareModalDomainTab({ noteId, enabled }: ShareModalDomainTabProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + + const { data: rules, isLoading, isError } = useDomainAccessForNote(noteId, enabled); + const createMutation = useCreateDomainAccess(noteId); + const deleteMutation = useDeleteDomainAccess(noteId); + + const [domainInput, setDomainInput] = useState(""); + const [roleInput, setRoleInput] = useState("viewer"); + const [pendingEditorConfirm, setPendingEditorConfirm] = useState<{ domain: string } | null>(null); + + const validation = useMemo(() => normalizeDomainInput(domainInput), [domainInput]); + const inputError: InputError = useMemo(() => { + if (validation.ok) return null; + if (domainInput.trim().length === 0) return null; + if (validation.error.kind === "invalid_format") return { kind: "invalid_format" }; + if (validation.error.kind === "free_email") { + return { kind: "free_email", domain: validation.error.domain }; + } + return null; + }, [validation, domainInput]); + + const submitCreate = async (domain: string) => { + try { + await createMutation.mutateAsync({ domain, role: roleInput }); + setDomainInput(""); + setRoleInput("viewer"); + toast({ title: t("notes.domainTabCreated") }); + } catch (error) { + // 事前にクライアント側で `normalizeDomainInput` を通しているため、サーバーが + // 400 を返すのは想定外のケース(ドメインリストの drift、競合エラーなど)。 + // 詳細メッセージはサーバー文言をそのまま `description` に出し、判別ロジックは + // 持たない(壊れやすい文字列マッチの代わりに `ApiError.message` を表示する)。 + // + // The client validates with `normalizeDomainInput` before submitting, so a + // server 400 here means something we couldn't pre-empt (list drift, + // conflict, etc.). Surface the server message verbatim instead of trying + // to classify it via fragile substring matching. + const description = error instanceof ApiError && error.message ? error.message : undefined; + toast({ + title: t("notes.domainTabCreateFailed"), + description, + variant: "destructive", + }); + } + }; + + const handleAddClick = () => { + if (!validation.ok) { + if (validation.error.kind === "empty") { + toast({ title: t("notes.domainTabCreateFailedEmpty"), variant: "destructive" }); + } + return; + } + if (roleInput === "editor") { + setPendingEditorConfirm({ domain: validation.domain }); + return; + } + void submitCreate(validation.domain); + }; + + const handleConfirmEditorAdd = () => { + if (!pendingEditorConfirm) return; + const { domain } = pendingEditorConfirm; + setPendingEditorConfirm(null); + void submitCreate(domain); + }; + + const handleRemove = async (rule: DomainAccessRow) => { + try { + await deleteMutation.mutateAsync({ accessId: rule.id }); + toast({ title: t("notes.domainTabRemoved") }); + } catch { + toast({ title: t("notes.domainTabRemoveFailed"), variant: "destructive" }); + } + }; + + return ( +
+
+

{t("notes.domainTabHeading")}

+

{t("notes.domainTabDescription")}

+
+ + + +
+ {isLoading ? ( +

{t("notes.domainTabLoading")}

+ ) : isError ? ( +

+ {t("notes.domainTabLoadFailed")} +

+ ) : !rules || rules.length === 0 ? ( +

{t("notes.domainTabNoRules")}

+ ) : ( + rules.map((rule) => ( + void handleRemove(target)} + removePending={deleteMutation.isPending} + /> + )) + )} +
+ + { + if (!open) setPendingEditorConfirm(null); + }} + > + + + {t("notes.domainTabRoleEditor")} + {t("notes.domainTabEditorWarning")} + + + + {t("common.cancel")} + + + {createMutation.isPending ? t("common.saving") : t("common.confirm")} + + + + +
+ ); +}