Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/hooks/useDomainAccess.ts
Original file line number Diff line number Diff line change
@@ -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<DomainAccessRow[]>({
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<DomainAccessRow, Error, CreateDomainAccessBody>({
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) });
},
});
}
24 changes: 23 additions & 1 deletion src/i18n/locales/en/notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,27 @@
"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…",
"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"
}
24 changes: 23 additions & 1 deletion src/i18n/locales/ja/notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,27 @@
"shareVisibilityDescriptionPublic": "誰でもこのノートを発見・閲覧できます。",
"shareVisibilityDescriptionRestricted": "招待されたメンバーのみ閲覧できます。URL を知っていてもアクセス権は付与されません。",
"shareUnlistedUrlHint": "このリンクをコピーして共有できます:",
"shareSaveChanges": "変更を保存"
"shareSaveChanges": "変更を保存",
"domainTabHeading": "ドメイン招待",
"domainTabDescription": "指定したドメインのメールでサインインしたユーザーは自動でアクセスできます。フリーメール(gmail.com / outlook.com など)は登録できません。",
"domainTabAddHeading": "ドメインルールを追加",
"domainPlaceholder": "example.com",
"domainTabAdd": "追加",
"domainTabRemove": "削除",
"domainTabRemoveAria": "{{domain}} を削除",
"domainTabRoleViewer": "閲覧者",
"domainTabRoleEditor": "編集者",
"domainTabNoRules": "ドメインルールはまだありません。",
"domainTabLoading": "ドメインルールを読み込み中…",
"domainTabRuleSummary": "{{domain}} のメールを持つユーザーは {{role}} として参加できます。",
"domainTabUnverifiedBadge": "未検証",
"domainTabUnverifiedHint": "ドメインの所有権は未検証です。DNS TXT による検証は今後のリリースで追加予定です。",
"domainTabEditorWarning": "このルールはドメイン全員に編集権限を渡します。閉じた組織内でのみ利用してください。",
"domainTabCreated": "ドメインルールを追加しました",
"domainTabCreateFailed": "ドメインルールの追加に失敗しました",
"domainTabCreateFailedFreeEmail": "{{domain}} はフリーメールのため、ドメインルールに登録できません。",
"domainTabCreateFailedInvalid": "ドメインの形式が正しくありません。",
"domainTabCreateFailedEmpty": "ドメインを入力してください。",
"domainTabRemoved": "ドメインルールを削除しました",
"domainTabRemoveFailed": "ドメインルールの削除に失敗しました"
}
47 changes: 47 additions & 0 deletions src/lib/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import type {
InviteLinkRedeemResponse,
CreateInviteLinkBody,
InviteLinkRow,
DomainAccessRow,
CreateDomainAccessBody,
} from "./types";

export type { NoteListItem };
Expand Down Expand Up @@ -576,6 +578,51 @@ export function createApiClient(options?: Partial<ApiClientOptions>) {
);
},

// ── 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<DomainAccessRow[]> {
return req<DomainAccessRow[]>(
"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<DomainAccessRow> {
return req<DomainAccessRow>(
"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 ───────────────────────────────────────────────────────

/**
Expand Down
28 changes: 28 additions & 0 deletions src/lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
50 changes: 50 additions & 0 deletions src/lib/domainValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Tests for the client-side domain validator (issue #663).
* クライアント側ドメイン検証のテスト。
*/
import { describe, it, expect } from "vitest";
import { normalizeDomainInput } from "./domainValidation";

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" },
});
});
});
120 changes: 120 additions & 0 deletions src/lib/domainValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* クライアント側のドメイン入力検証 (`note_domain_access`, issue #663)。
* サーバー側 `server/api/src/lib/freeEmailDomains.ts` のロジックをミラーし、
* UI でのインライン警告に使う。最終判定はサーバー側で行うため、これは
* 「送信前に明確に弾けるものを早めに弾く」目的のソフトな検証。
*
* Client-side domain input validation for `note_domain_access` (issue #663).
* Mirrors `server/api/src/lib/freeEmailDomains.ts` so the share-modal domain
* tab can warn users before they submit. The server remains the source of
* truth — this is intentionally a soft pre-check.
*/

/**
* 拒否対象の無料メールドメイン(小文字・`@` なし)。サーバーの拒否リストと
* 同期させる。差分が出てもサーバーが最終判定するため致命的ではない。
*
* Free-webmail providers blocked for domain rules. Kept in sync with the
* server list; small drift is non-fatal because the server enforces the truth.
*/
export const FREE_EMAIL_DOMAINS: ReadonlySet<string> = 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",
]);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated

/**
* ドメイン検証エラーの判別共用体。
* Discriminated error kinds for client-side domain validation.
*/
export type DomainValidationError =
| { kind: "empty" }
| { kind: "invalid_format" }
| { kind: "free_email"; domain: string };

/**
* 入力検証の結果。成功時は正規化済みドメイン、失敗時は理由。
* Validation result — normalised domain on success, otherwise an error kind.
*/
export type DomainValidationResult =
| { ok: true; domain: string }
| { ok: false; error: DomainValidationError };

/**
* RFC 1035 ベースのラフなドメイン検証。サーバーのものと同一。
* Lightweight RFC 1035 domain check; mirrors the server regex.
*/
const DOMAIN_REGEX = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;

/**
* 生のドメイン入力を正規化・検証する。
*
* Normalise and validate a raw domain input:
* - trim & lower-case
* - strip a single leading `@` (so `@example.com` and `example.com` both work)
* - 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 };
}
Loading
Loading