From e0e4a1847689cf15ad55cbd9295e95ab80767adb Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 16 Oct 2026 15:20:28 +0000 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=81=D1=82=D1=83=D0=BF=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B0=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D1=96=D0=B2?= =?UTF-8?q?=20CRM:=20=D1=87=D1=83=D0=B6=D0=BE=D0=B5=20=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=20access=5Fgrants=20(projects?= =?UTF-8?q?=3Dall,=20level=20read/full),=20custom=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20shared=3Dall;=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=20=C2=AB=D0=BB=D0=B8=D1=88=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=BB=D1=8F=D0=B4=C2=BB=20(403=20=D0=BD=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F,=20access=20?= =?UTF-8?q?=D0=B2=20/api/me=20=D0=B8=20/api/tree);=20=D0=BA=D1=8D=D1=88=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20=D0=BA=20workspace?= =?UTF-8?q?=20=E2=80=94=201=20=D0=BC=D0=B8=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- SPEC.md | 1 + .../Wiki.Api.Tests/WorkspaceAccessTests.cs | 120 +++++++++++++++++- backend/Wiki.Api/Auth.cs | 102 +++++++++++---- backend/Wiki.Api/Program.cs | 12 +- frontend/src/App.tsx | 27 ++-- frontend/src/PageView.tsx | 3 +- frontend/src/Tree.tsx | 6 +- frontend/src/api.ts | 3 +- frontend/src/crm.ts | 3 +- 10 files changed, 235 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index e1ca3a9..38fad58 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ | `Authorization` | `Bearer ` | | `X-Wiki-Workspace` | `shared` (по умолчанию), `personal:` или id пользовательского workspace CRM (например, `wsmv5l7d425re160`, как отдаёт `crm workspaces list`) | -Сервер проверяет токен: `exp`, `aud = "authenticated"` и `GET /auth/v1/user` с publishable-ключом (результат кэшируется на 5 минут по SHA-256 токена). Workspace из запроса не доверяется: `shared` доступен всем, `personal:` только владельцу `uid`, id пользовательского workspace проверяется запросом `GET /rest/v1/workspaces?id=eq.` с токеном пользователя (RLS Supabase: строку видят только владелец и участники). Id в таблице `workspaces` CRM текстовые: `shared`, `personal:` и для пользовательских `ws` + base36 (`createWorkspace` в CRM). Принимается любой id из `[A-Za-z0-9_-]{1,64}` (в том числе uuid), другие символы отклоняются ещё до запроса к Supabase. Ответы: `401` — токен не принят или истёк, `403` — нет доступа к workspace. +Сервер проверяет токен: `exp`, `aud = "authenticated"` и `GET /auth/v1/user` с publishable-ключом (результат кэшируется на 5 минут по SHA-256 токена). Workspace из запроса не доверяется: `shared` доступен всем; `personal:` — владельцу `uid`, а другим — по выдаче доступа CRM («Доступ для акаунтів», таблица `access_grants`, строка `:`): `GET /rest/v1/access_grants?owner_uid=eq.&grantee_uid=eq.<я>` с токеном пользователя, личную вики открывает `projects = all` («Мої особисті проєкти: Усі»), права — `level` (`read` — лишь просмотр, `full` — полные); «Вибрані» проекты и один лишь план вики не открывают. Id пользовательского workspace проверяется запросом `GET /rest/v1/workspaces?id=eq.&select=id,owner_uid,members` с токеном пользователя (RLS Supabase): владелец и участники — полные права; строка видна, но я не владелец и не участник — значит, её открыли через `access_grants.shared = all` владельцем или участником, права по `level`. Результат проверки workspace кэшируется на 1 минуту, поэтому выданный или отозванный в CRM доступ вступает в силу в пределах минуты. В workspace «лишь просмотр» любой запрос, кроме `GET`/`HEAD`, получает `403`; `/api/me` и `/api/tree` возвращают `access: "read" | "full"`, интерфейс в режиме `read` прячет кнопки изменения. Id в таблице `workspaces` CRM текстовые: `shared`, `personal:` и для пользовательских `ws` + base36 (`createWorkspace` в CRM). Принимается любой id из `[A-Za-z0-9_-]{1,64}` (в том числе uuid), другие символы отклоняются ещё до запроса к Supabase. Ответы: `401` — токен не принят или истёк, `403` — нет доступа к workspace. -Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:`, иначе `id`. Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`. +Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:` (свой), иначе `id` (в том числе `personal:` с `kind: "granted"` — чужое личное, открытое мне). Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`. ## Ссылки на страницы в CRM @@ -39,8 +39,8 @@ | `GET` | `/health` | `{ status, dbCreatedAt }` — без авторизации; `dbCreatedAt` не меняется, пока жив том с базой | | `GET` | `/api/config` | `{ crmOrigins: [...] }` — без авторизации | | `POST` | `/api/verify` | `{ access_token }` → `{ ok, id, email, name, exp }` — без авторизации | -| `GET` | `/api/me` | текущий пользователь и workspace | -| `GET` | `/api/tree` | `{ workspace, nodes: [Node] }` — все папки и страницы workspace без текста | +| `GET` | `/api/me` | текущий пользователь, workspace и `access` (`read` / `full`) | +| `GET` | `/api/tree` | `{ workspace, access, nodes: [Node] }` — все папки и страницы workspace без текста | | `GET` | `/api/nodes/{id}` | папка или страница (Page) | | `GET` | `/api/pages/{id}` | страница (Page), `404` для папки | | `GET` | `/api/pages/{id}/markdown` | текст страницы, `text/markdown` | diff --git a/SPEC.md b/SPEC.md index b5e24ed..4d3bcb4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -24,6 +24,7 @@ - Сейчас CRM workspace во встроенную страницу не передаёт: в `crm:auth` только `access_token`, `expires_at`, `user { id, email, name }` (проверено по `js/pagebridge.js` 2026-10-13). Выбранный workspace CRM держит в `localStorage`. - Нужна доработка протокола от Олега (задача в CRM): поле `workspace: { id, kind, title }` в `crm:auth` и новое сообщение при переключении workspace. - Сервер вики id из сообщения не доверяет, а сверяет доступ: `personal:` только для владельца, `shared` для всех, `custom` проверяется запросом к таблице `workspaces` Supabase с токеном пользователя (RLS). +- Доступ для акаунтів (CRM, 2026-10): владелец открывает другому акаунту свой личный воркспейс (`access_grants`: `projects` none/selected/all, `level` read/full, `plan`, `shared`). Личную вики открывает `projects = all`, права — `level`; через `shared = all` открываются и спільні воркспейсы владельца с правами `level`. `level = read` — вики только для чтения. - У каждой папки и страницы поле `workspace_id`, дерево показывает только текущий workspace. ## Первая версия diff --git a/backend/Wiki.Api.Tests/WorkspaceAccessTests.cs b/backend/Wiki.Api.Tests/WorkspaceAccessTests.cs index 7cb1bc8..5763bbb 100644 --- a/backend/Wiki.Api.Tests/WorkspaceAccessTests.cs +++ b/backend/Wiki.Api.Tests/WorkspaceAccessTests.cs @@ -10,16 +10,41 @@ public class WorkspaceAccessTests private const string Uid = "11111111-1111-1111-1111-111111111111"; private static readonly WikiUser User = new(Uid, "u@test", "U", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds()); - /// Фальшивый Supabase: отвечает на /rest/v1/workspaces строкой, если id в списке «видимых» пользователю. + private const string Owner = "33333333-3333-3333-3333-333333333333"; + + /// + /// Фальшивый Supabase: /rest/v1/workspaces отдаёт строку, если id в списке «видимых» пользователю (по умолчанию он + /// её участник); /rest/v1/access_grants — строки Grants, отфильтрованные как PostgREST (owner_uid, grantee_uid, shared). + /// private sealed class FakeSupabase(params string[] visible) : HttpMessageHandler { public List Requests { get; } = []; + public string WsOwner { get; set; } = Owner; + public string[] WsMembers { get; set; } = [Uid]; + public List<(string Owner, string Grantee, string Projects, string Level, string Shared)> Grants { get; } = []; + public HttpStatusCode GrantsStatus { get; set; } = HttpStatusCode.OK; + protected override Task SendAsync(HttpRequestMessage req, CancellationToken ct) { Requests.Add(req.RequestUri!); var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri!.Query); - var id = (q["id"] ?? "").Replace("eq.", ""); - var body = visible.Contains(id) ? $"[{{\"id\":\"{id}\"}}]" : "[]"; + string? Eq(string k) => q[k]?.Replace("eq.", ""); + string body; + if (req.RequestUri.AbsolutePath == "/rest/v1/access_grants") + { + if (GrantsStatus != HttpStatusCode.OK) return Task.FromResult(new HttpResponseMessage(GrantsStatus)); + var rows = Grants.Where(g => (Eq("owner_uid") is not { } o || g.Owner == o) + && (Eq("grantee_uid") is not { } u || g.Grantee == u) + && (Eq("shared") is not { } sh || g.Shared == sh)) + .Select(g => $"{{\"owner_uid\":\"{g.Owner}\",\"projects\":\"{g.Projects}\",\"level\":\"{g.Level}\"}}"); + body = "[" + string.Join(",", rows) + "]"; + } + else + { + var id = Eq("id") ?? ""; + var members = string.Join(",", WsMembers.Select(m => $"\"{m}\"")); + body = visible.Contains(id) ? $"[{{\"id\":\"{id}\",\"owner_uid\":\"{WsOwner}\",\"members\":[{members}]}}]" : "[]"; + } return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }); } } @@ -73,7 +98,7 @@ public class WorkspaceAccessTests var (auth, fake) = Make(); Assert.True(await auth.CanAccessAsync(User, "token", "shared", default)); Assert.True(await auth.CanAccessAsync(User, "token", $"personal:{Uid}", default)); - Assert.False(await auth.CanAccessAsync(User, "token", "personal:someone-else", default)); + Assert.False(await auth.CanAccessAsync(User, "token", "personal:someone-else", default)); // не uuid — без запроса Assert.Empty(fake.Requests); } @@ -107,4 +132,91 @@ public class WorkspaceAccessTests Assert.True(await auth.CanAccessAsync(User, "token", "wsmv5l7d425re160", default)); Assert.Single(fake.Requests); } + + // ------------------------------------------------------------ чужое личное: access_grants + + [Fact] + public async Task OthersPersonal_DeniedWithoutGrant() + { + var (auth, fake) = Make(); + Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); + var uri = Assert.Single(fake.Requests); + Assert.Equal("/rest/v1/access_grants", uri.AbsolutePath); + Assert.Contains($"owner_uid=eq.{Owner}", uri.Query); + Assert.Contains($"grantee_uid=eq.{Uid}", uri.Query); + } + + [Theory] + [InlineData("all", "full", SupabaseAuth.Access.Full)] + [InlineData("all", "read", SupabaseAuth.Access.Read)] + [InlineData("selected", "full", SupabaseAuth.Access.None)] // вибрані проєкти вики не открывают + [InlineData("none", "full", SupabaseAuth.Access.None)] // только план — тоже + public async Task OthersPersonal_ByGrantProjects(string projects, string level, SupabaseAuth.Access expected) + { + var (auth, fake) = Make(); + fake.Grants.Add((Owner, Uid, projects, level, "none")); + Assert.Equal(expected, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); + } + + [Fact] + public async Task OthersPersonal_GrantToSomeoneElse_DoesNotCount() + { + var (auth, fake) = Make(); + fake.Grants.Add((Owner, "44444444-4444-4444-4444-444444444444", "all", "full", "none")); + Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); + } + + [Fact] + public async Task OthersPersonal_SupabaseError_DeniedAndNotCached() + { + var (auth, fake) = Make(); + fake.Grants.Add((Owner, Uid, "all", "full", "none")); + fake.GrantsStatus = HttpStatusCode.InternalServerError; + Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); + fake.GrantsStatus = HttpStatusCode.OK; + Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", $"personal:{Owner}", default)); + } + + [Theory] + [InlineData("personal:")] + [InlineData("personal:x&owner_uid=neq.0")] + [InlineData("personal:33333333-3333-3333-3333-333333333333&select=*")] + public async Task OthersPersonal_NonUuidOwner_RejectedBeforeSupabase(string id) + { + var (auth, fake) = Make(); + Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", id, default)); + Assert.Empty(fake.Requests); + } + + // ------------------------------------------------------------ custom: участник или через access_grants.shared + + [Fact] + public async Task Custom_OwnerOrMember_Full() + { + var (auth, fake) = Make("wsmv5l7d425re160", "wsmv5l7d425re161"); + Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default)); // участник + fake.WsOwner = Uid; fake.WsMembers = []; + Assert.Equal(SupabaseAuth.Access.Full, await auth.AccessAsync(User, "token", "wsmv5l7d425re161", default)); // владелец + Assert.DoesNotContain(fake.Requests, u => u.AbsolutePath == "/rest/v1/access_grants"); + } + + [Theory] + [InlineData("full", SupabaseAuth.Access.Full)] + [InlineData("read", SupabaseAuth.Access.Read)] + public async Task Custom_VisibleViaSharedGrant_LevelFromGrant(string level, SupabaseAuth.Access expected) + { + var (auth, fake) = Make("wsmv5l7d425re160"); + fake.WsMembers = ["55555555-5555-5555-5555-555555555555"]; + fake.Grants.Add(("55555555-5555-5555-5555-555555555555", Uid, "none", level, "all")); + Assert.Equal(expected, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default)); + } + + [Fact] + public async Task Custom_VisibleButNotMemberAndNoGrant_Denied() + { + var (auth, fake) = Make("wsmv5l7d425re160"); + fake.WsMembers = []; + fake.Grants.Add((Owner, Uid, "all", "full", "none")); // личное открыто, спільні — нет + Assert.Equal(SupabaseAuth.Access.None, await auth.AccessAsync(User, "token", "wsmv5l7d425re160", default)); + } } diff --git a/backend/Wiki.Api/Auth.cs b/backend/Wiki.Api/Auth.cs index 4962283..c892e08 100644 --- a/backend/Wiki.Api/Auth.cs +++ b/backend/Wiki.Api/Auth.cs @@ -18,8 +18,10 @@ public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/'); private readonly string _key = cfg["Supabase:PublishableKey"] ?? ""; private readonly ConcurrentDictionary _users = new(); - private readonly ConcurrentDictionary _ws = new(); + private readonly ConcurrentDictionary _ws = new(); private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5); + // Доступ к workspace кэшируем коротко: выданный или отозванный в CRM доступ вступает в силу в пределах минуты. + private static readonly TimeSpan WsCacheFor = TimeSpan.FromMinutes(1); public const string Shared = "shared"; @@ -76,36 +78,90 @@ public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration } } + /// Права в workspace: нет, только чтение, полные. + public enum Access { None, Read, Full } + + public async Task CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) => + await AccessAsync(user, token, workspaceId, ct) != Access.None; + /// /// Нормализует и проверяет workspace. id из клиента не доверяем: - /// shared — для всех, personal:<uid> — только владельцу, остальные (custom, напр. wsmv5l7d425re160) — - /// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник. + /// shared — для всех; personal:<uid> — владельцу, а другим — по выдаче доступа CRM (access_grants); + /// остальные (custom, напр. wsmv5l7d425re160) — запросом к таблице workspaces Supabase с токеном пользователя + /// (RLS отдаёт строку владельцу, участнику и тому, кому открыли спільні воркспейси через access_grants.shared). /// - public async Task CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) + public async Task AccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) { - if (workspaceId == Shared) return true; + if (workspaceId == Shared) return Access.Full; + string? owner = null; if (workspaceId.StartsWith("personal:", StringComparison.Ordinal)) - return workspaceId == $"personal:{user.Id}"; - if (!CustomWsRx().IsMatch(workspaceId)) return false; + { + owner = workspaceId["personal:".Length..]; + if (owner == user.Id) return Access.Full; + if (!Guid.TryParse(owner, out _)) return Access.None; // uid Supabase — uuid; значение идёт в фильтр PostgREST + } + else if (!CustomWsRx().IsMatch(workspaceId)) return Access.None; var now = DateTimeOffset.UtcNow; var key = user.Id + "|" + workspaceId; - if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok; - bool ok; - try - { - using var req = new HttpRequestMessage(HttpMethod.Get, - $"{_url}/rest/v1/workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id"); - req.Headers.Add("apikey", _key); - req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - using var r = await http.CreateClient("supabase").SendAsync(req, ct); - if (!r.IsSuccessStatusCode) return false; // ошибку не кэшируем - using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct)); - ok = doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0; - } - catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return false; } - _ws[key] = (ok, now + CacheFor); - return ok; + if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Access; + Access? got; + try { got = owner is not null ? await PersonalGrantAsync(user, token, owner, ct) : await CustomAsync(user, token, workspaceId, ct); } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return Access.None; } + if (got is not { } access) return Access.None; // ошибку не кэшируем + _ws[key] = (access, now + WsCacheFor); + return access; + } + + /// + /// Чужое личное: строка access_grants владельца для меня (RLS отдаёт адресату его строки). Личная вики — часть + /// личного воркспейса, поэтому её открывает «Мої особисті проєкти: Усі» (projects = all), права — level (read/full). + /// «Вибрані» проєкты и один лишь план вики не открывают. + /// + private async Task PersonalGrantAsync(WikiUser user, string token, string owner, CancellationToken ct) + { + var rows = await SelectAsync(token, + $"access_grants?owner_uid=eq.{owner}&grantee_uid=eq.{Uri.EscapeDataString(user.Id)}&select=projects,level", ct); + if (rows is null) return null; + foreach (var g in rows.Value.EnumerateArray()) + if (Str(g, "projects") == "all") return Str(g, "level") == "full" ? Access.Full : Access.Read; + return Access.None; + } + + /// + /// Свой custom (владелец или участник) — полные права. Строка видна, но я не владелец и не участник — значит, + /// её открыли через access_grants.shared = all владельцем или участником этого воркспейса: права по level, как в CRM. + /// + private async Task CustomAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) + { + var ws = await SelectAsync(token, $"workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id,owner_uid,members", ct); + if (ws is null) return null; + if (ws.Value.GetArrayLength() == 0) return Access.None; + var row = ws.Value[0]; + var people = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Str(row, "owner_uid") is { } o) people.Add(o); + if (row.TryGetProperty("members", out var m) && m.ValueKind == JsonValueKind.Array) + foreach (var x in m.EnumerateArray()) if (x.ValueKind == JsonValueKind.String) people.Add(x.GetString()!); + if (people.Contains(user.Id)) return Access.Full; + + var grants = await SelectAsync(token, + $"access_grants?grantee_uid=eq.{Uri.EscapeDataString(user.Id)}&shared=eq.all&select=owner_uid,level", ct); + if (grants is null) return null; + var via = grants.Value.EnumerateArray().Where(g => Str(g, "owner_uid") is { } ow && people.Contains(ow)).ToList(); + if (via.Count == 0) return Access.None; + return via.Any(g => Str(g, "level") == "full") ? Access.Full : Access.Read; + } + + /// GET /rest/v1/<query> с токеном пользователя. null — ошибка (не 2xx или не массив). + private async Task SelectAsync(string token, string query, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/rest/v1/{query}"); + req.Headers.Add("apikey", _key); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var r = await http.CreateClient("supabase").SendAsync(req, ct); + if (!r.IsSuccessStatusCode) return null; + using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct)); + return doc.RootElement.ValueKind == JsonValueKind.Array ? doc.RootElement.Clone() : null; } private void Sweep(DateTimeOffset now) diff --git a/backend/Wiki.Api/Program.cs b/backend/Wiki.Api/Program.cs index b793503..877f102 100644 --- a/backend/Wiki.Api/Program.cs +++ b/backend/Wiki.Api/Program.cs @@ -100,13 +100,18 @@ app.Use(async (ctx, next) => } var ws = ctx.Request.Headers["X-Wiki-Workspace"].ToString().Trim(); if (ws.Length == 0) ws = SupabaseAuth.Shared; - if (!await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted)) { await Deny(ctx, 403, "немає доступу до воркспейсу"); return; } + var access = await sa.AccessAsync(user, token, ws, ctx.RequestAborted); + if (access == SupabaseAuth.Access.None) { await Deny(ctx, 403, "немає доступу до воркспейсу"); return; } + // Доступ «лише перегляд» (выдача CRM с level = read): читать можно, менять — нет. + if (access == SupabaseAuth.Access.Read && !HttpMethods.IsGet(ctx.Request.Method) && !HttpMethods.IsHead(ctx.Request.Method)) + { await Deny(ctx, 403, "воркспейс відкрито лише для перегляду"); return; } // Профиль (имя, аватар из довідника CRM) — для поля «автор»; сбой не мешает запросу. try { await ctx.RequestServices.GetRequiredService().EnsureAsync(user, token, ctx.RequestAborted); } catch (Exception e) when (e is not OperationCanceledException) { /* профиль обновится в следующий раз */ } ctx.Items["user"] = user; ctx.Items["token"] = token; ctx.Items["ws"] = ws; + ctx.Items["access"] = access; await next(); }); @@ -117,6 +122,7 @@ static Task Deny(HttpContext ctx, int status, string error) } static WikiUser U(HttpContext c) => (WikiUser)c.Items["user"]!; static string Ws(HttpContext c) => (string)c.Items["ws"]!; +static string Acc(HttpContext c) => (SupabaseAuth.Access)c.Items["access"]! == SupabaseAuth.Access.Read ? "read" : "full"; // ---------------------------------------------------------------- статика SPA var types = new FileExtensionContentTypeProvider(); @@ -153,9 +159,9 @@ app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) => // ---------------------------------------------------------------- вики var api = app.MapGroup("/api"); -api.MapGet("/me", (HttpContext c) => { var u = U(c); return Results.Ok(new { id = u.Id, email = u.Email, name = u.Name, exp = u.Exp, workspace = Ws(c) }); }); +api.MapGet("/me", (HttpContext c) => { var u = U(c); return Results.Ok(new { id = u.Id, email = u.Email, name = u.Name, exp = u.Exp, workspace = Ws(c), access = Acc(c) }); }); -api.MapGet("/tree", (HttpContext c, Store s) => Results.Ok(new { workspace = Ws(c), nodes = s.Tree(Ws(c)) })); +api.MapGet("/tree", (HttpContext c, Store s) => Results.Ok(new { workspace = Ws(c), access = Acc(c), nodes = s.Tree(Ws(c)) })); api.MapGet("/nodes/{id}", (HttpContext c, Store s, string id) => s.Get(Ws(c), id) is { } p ? Results.Ok(p) : Results.NotFound(new { error = "не знайдено" })); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2bbf4d3..94dbd12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -99,9 +99,10 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { const [hits, setHits] = useState(null); const [sideOpen, setSideOpen] = useState(false); const [editing, setEditing] = useState(false); + const [readOnly, setReadOnly] = useState(false); // воркспейс открыт мне в CRM лишь для просмотра const reloadTree = useCallback(async () => { - try { setNodes((await Api.tree()).nodes); setTreeErr(""); } + try { const t = await Api.tree(); setNodes(t.nodes); setReadOnly(t.access === "read"); setTreeErr(""); } catch (e) { setTreeErr((e as Error).message); } }, []); @@ -153,7 +154,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { const current: Node | undefined = currentId ? byId.get(currentId) : undefined; const crumbs: Crumb[] = page && page.id === currentId ? page.path : []; - const wsLabel = props.ws === "shared" ? "Загальний" : props.ws.startsWith("personal:") ? "Мій" : props.wsTitle || "Воркспейс"; + const wsLabel = props.ws === "shared" ? "Загальний" : props.wsTitle || (props.ws.startsWith("personal:") ? "Мій" : "Воркспейс"); const listing = (parentId: string | null) => { const list = kids.get(parentId) ?? []; @@ -166,7 +167,13 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { )); }; - const actions = (n: Node) => ( + const actions = (n: Node) => readOnly ? ( +
+ + {n.kind === "page" && } +
+ ) : (
{n.kind === "page" && !editing && ( @@ -188,12 +195,12 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { if (route.kind === "root") { content = <>
-

Вікі

Воркспейс: {wsLabel}
+

Вікі

Воркспейс: {wsLabel}{readOnly && " · лише перегляд"}
-
+ {!readOnly &&
-
+
}

Вміст {kids.get(null)?.length ?? 0}

{nodes ? listing(null) :
Завантаження…
} ; @@ -211,7 +218,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { ; } else { content = <> - { setNodes((list) => list?.map((x) => (x.id === p.id ? { ...x, title: p.title, version: p.version, updatedAt: p.updatedAt, updatedByName: p.updatedByName } : x)) ?? list); if (titleChanged) void reloadTree(); @@ -246,16 +253,16 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { <>

{wsLabel}

-
+ {!readOnly &&
-
+
}
{ e.preventDefault(); void navigate("/"); }}> Головна {treeErr &&
{treeErr}
} - {nodes ? setDlg({ kind: "new", what: "page", parentId: pid })} /> + {nodes ? setDlg({ kind: "new", what: "page", parentId: pid })} /> : !treeErr &&
Завантаження…
} )} diff --git a/frontend/src/PageView.tsx b/frontend/src/PageView.tsx index 68a5b27..907928a 100644 --- a/frontend/src/PageView.tsx +++ b/frontend/src/PageView.tsx @@ -20,6 +20,7 @@ export const markOpenInEdit = (id: string) => openInEdit.add(id); */ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page, titleChanged: boolean) => void; onModeChange?: (editing: boolean) => void; + readOnly?: boolean; }) { const [editing, setEditing] = useState(() => openInEdit.delete(props.page.id)); const [leaveMsg, setLeaveMsg] = useState(""); @@ -149,7 +150,7 @@ export function PageView(props: { {editing ? ( - ) : ( + ) : !props.readOnly && ( )}
diff --git a/frontend/src/Tree.tsx b/frontend/src/Tree.tsx index d2323bf..41df217 100644 --- a/frontend/src/Tree.tsx +++ b/frontend/src/Tree.tsx @@ -22,7 +22,7 @@ export function childrenMap(nodes: Node[]) { const storeKey = (ws: string) => `wiki.open.${ws}`; /** Дерево папок и страниц: раскрыть/свернуть, текущий узел подсвечен, предки раскрыты. */ -export function Tree(props: { nodes: Node[]; ws: string; currentId: string | null; onAddPage: (parentId: string) => void }) { +export function Tree(props: { nodes: Node[]; ws: string; currentId: string | null; onAddPage?: (parentId: string) => void }) { const kids = useMemo(() => childrenMap(props.nodes), [props.nodes]); const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]); const [open, setOpen] = useState>(() => { @@ -71,8 +71,8 @@ export function Tree(props: { nodes: Node[]; ws: string; currentId: string | nul {n.title} - {n.kind === "folder" && ( - )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 03012b9..8589d5f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -40,7 +40,8 @@ export async function api(path: string, init: RequestInit = {}, ws = cu const json = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) }); export const Api = { - tree: () => api<{ workspace: string; nodes: Node[] }>("/api/tree"), + /** access: read — воркспейс открыт мне в CRM лишь для просмотра (сервер не примет изменений). */ + tree: () => api<{ workspace: string; access: "read" | "full"; nodes: Node[] }>("/api/tree"), page: (id: string) => api(`/api/nodes/${encodeURIComponent(id)}`), createPage: (title: string, parentId: string | null, markdown = "") => api("/api/pages", json("POST", { title, parentId, markdown })), diff --git a/frontend/src/crm.ts b/frontend/src/crm.ts index e786c4c..f361689 100644 --- a/frontend/src/crm.ts +++ b/frontend/src/crm.ts @@ -70,8 +70,9 @@ function wsKey(w: CrmWorkspace | null | undefined, uid: string | undefined): { i const id = String(w.id ?? ""); const kind = String(w.kind ?? ""); if (kind === "shared" || id === "shared" || (!id && !kind)) return { id: "shared", title: w.title }; - if (kind === "personal" || id === "personal" || id.startsWith("personal:")) + if (kind === "personal" || id === "personal") return uid ? { id: `personal:${uid}`, title: w.title } : { id: "shared" }; + // Иначе id как есть — custom или personal: чужого, открытое мне в CRM (kind: "granted"); доступ проверит сервер. return { id, title: w.title }; }