From 8cc21f17c547f0f38619ed50ef3241c28768efce Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 09:46:51 +0200 Subject: [PATCH 1/8] feat(notify): add notif to internal users when granted - add coalesced protection to avoid mail bombing if an invited goes many grant in a short period - add resentd method in share menu item (work for both internal and external users) - user can disable email notification via his properties - add env variable from admin to disable notifications --- docs/config/env.md | 1 + example.env | 20 + .../20260624000000_users_notify_on_share.sql | 38 + src/application/dtos/grant_dto.rs | 99 +++ src/application/dtos/user_dto.rs | 16 + src/application/ports/auth_ports.rs | 6 + .../services/auth_application_service.rs | 24 + .../services/magic_link_invite_service.rs | 23 +- src/application/services/mod.rs | 1 + .../recipient_notification_service.rs | 680 ++++++++++++++++++ src/common/config.rs | 18 + src/common/di.rs | 44 +- src/domain/entities/user.rs | 184 +++++ src/domain/repositories/user_repository.rs | 7 + src/domain/services/authorization.rs | 7 + .../repositories/pg/user_pg_repository.rs | 97 ++- src/infrastructure/services/pg_acl_engine.rs | 47 +- src/interfaces/api/handlers/grant_handler.rs | 269 ++++++- src/interfaces/api/routes.rs | 1 + static/js/components/mySharesList.js | 81 +++ static/js/components/shareModal.js | 74 +- static/js/core/icons.js | 4 + static/js/core/types.js | 2 + static/js/model/grants.js | 20 +- static/js/views/profile/profile.js | 19 +- static/locales/ar.json | 10 +- static/locales/de.json | 10 +- static/locales/en.json | 18 + static/locales/es.json | 10 +- static/locales/fa.json | 10 +- static/locales/fr.json | 10 +- static/locales/hi.json | 10 +- static/locales/it.json | 10 +- static/locales/ja.json | 10 +- static/locales/ko.json | 10 +- static/locales/nl.json | 10 +- static/locales/pl.json | 10 +- static/locales/pt.json | 10 +- static/locales/ru.json | 10 +- static/locales/zh-TW.json | 10 +- static/locales/zh.json | 10 +- static/profile.html | 7 + tests/api/external_users.hurl | 12 +- tests/api/grants.hurl | 8 +- tests/api/grants_nested_groups.hurl | 10 +- 45 files changed, 1906 insertions(+), 81 deletions(-) create mode 100644 migrations/20260624000000_users_notify_on_share.sql create mode 100644 src/application/services/recipient_notification_service.rs diff --git a/docs/config/env.md b/docs/config/env.md index 57579572..e797861f 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -216,6 +216,7 @@ Configures the invite-by-email and login-via-email flows. Both require SMTP to b | `OXICLOUD_MAGIC_LINK_TTL_HOURS` | `24` | Lifetime of a freshly-minted magic-link token, in hours | | `OXICLOUD_ALLOW_EXTERNAL_USERS` | `true` | Kill switch for the whole flow. `false` makes `POST /api/grants` reject `subject.type = "email"` for unknown addresses and `POST /api/auth/magic-link/send` return its uniform stub without issuing a token. | | `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted when minting a new external user (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed, subject to `OXICLOUD_ALLOW_EXTERNAL_USERS`. Subdomains must be listed explicitly: `partner.com` does NOT match `eng.partner.com`. Example: `partner-a.com,partner-b.io`. | +| `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` | `true` | Operator-level kill switch for the **plain-notification** email arm — the "Alice shared 'Project Alpha' with you" mail that fires when the recipient is a password user or OIDC user (i.e. not magic-link eligible). `false` suppresses the arm entirely; internal users discover new shares only at next login. A coarser knob than the per-user `auth.users.notify_on_share` column; when this is `false` the user-level opt-in does not matter. External-user magic-link **first-invitations** are unaffected and always send. | ## Internationalization (server-rendered surfaces) diff --git a/example.env b/example.env index f2e0c99e..bbff9ffb 100644 --- a/example.env +++ b/example.env @@ -416,6 +416,26 @@ OXICLOUD_WOPI_ENABLED=false # IdP is the security boundary and may enforce MFA we shouldn't bypass. #OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=false +# Operator-level kill switch for share-notification emails to internal +# users (the "Alice shared 'Project Alpha' with you" mail that fires when +# `magic_link_eligibility` rejects the recipient — typically password +# users and OIDC users with a known email). When `true` (default), the +# new RecipientNotificationService dispatches plain-notification mail +# on every share. When `false`, internal users discover new shares only +# at next login. +# +# This is a coarser knob than the per-user +# `auth.users.notify_on_share` column (set via the profile "Email me +# when someone shares with me" checkbox): when this env is `false`, +# the per-user opt-in does not matter. +# +# External-user magic-link FIRST-invitations are NOT affected by this +# flag — those always send, because the link is the only way the +# recipient can claim the share for the first time. Subsequent shares +# to an existing external follow the same plain-notification path and +# are subject to both this knob and the per-user opt-out. +#OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE=true + # ----------------------------------------------------------------------------- # INTERNATIONALIZATION (server-rendered surfaces) # ----------------------------------------------------------------------------- diff --git a/migrations/20260624000000_users_notify_on_share.sql b/migrations/20260624000000_users_notify_on_share.sql new file mode 100644 index 00000000..62c22d15 --- /dev/null +++ b/migrations/20260624000000_users_notify_on_share.sql @@ -0,0 +1,38 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Per-user opt-out for share-notification emails (PR N1, share-notification +-- pipeline). Pairs with the operator kill switch +-- `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` — the env flag affects all +-- internal-user sends; this column scopes the decision to one recipient. +-- ════════════════════════════════════════════════════════════════════════════ +-- TRUE = the user wants email when someone shares a resource with them +-- (default for both pre-existing and freshly-created rows). +-- FALSE = the user has unchecked the profile checkbox; the share grant is +-- still created normally, but `RecipientNotificationService` returns +-- `NotApplicable { reason: "recipient_opted_out" }` and no mail is +-- dispatched. The granter sees a clear toast. +-- +-- Applies uniformly to the plain-notification arm — the path that fires for +-- internal users, OIDC users, and password users. Magic-link invitations to +-- newly-provisioned external users always send regardless of this column, +-- because the link is the only way the external user can sign in for the +-- first time; suppressing it would lock them out of the share entirely. +-- (Once they have an account and have opted out, subsequent shares from +-- other granters do honor the flag.) +-- +-- DEFAULT TRUE matches the pre-PR-N1 behaviour for external users (they +-- always received invitations); for internal users it ships the new +-- "you've been shared a folder" notification turned on by default. A +-- noisier inbox is the trade-off; the checkbox is the safety valve. + +ALTER TABLE auth.users + ADD COLUMN notify_on_share BOOLEAN NOT NULL DEFAULT TRUE; + +COMMENT ON COLUMN auth.users.notify_on_share IS + 'Per-user opt-out for share-notification emails. TRUE (default) = + receive an email when someone grants access to a resource; + FALSE = grant still created, but no mail is sent + (RecipientNotificationService returns NotApplicable). The + operator-level kill switch OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE + is a separate, broader knob; this column is the per-user fine + grain. Magic-link first-invitations to externals bypass the + check — see the column comment for the rationale.'; diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index 4d551c34..ac74a225 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -262,6 +262,98 @@ impl From for GrantDto { } } +// ════════════════════════════════════════════════════════════════════════════ +// Notification DTOs (PR N1) — surfaced in the create-grant and /notify +// responses so the frontend can show actionable toasts ("Notified Carol", +// "Carol already notified recently", "Notified 8 of 10 group members"). +// ════════════════════════════════════════════════════════════════════════════ + +/// One per resolved recipient. `kind` discriminates; sibling fields are +/// only meaningful for the matching variant. Tagged JSON shape: +/// +/// ```json +/// { "kind": "sent", "detail": "magic_link" } +/// { "kind": "sent", "detail": "plain_notification" } +/// { "kind": "coalesced", "last_sent_at": "2026-06-04T12:00:00Z" } +/// { "kind": "rate_limited", "retry_after_secs": 1800 } +/// { "kind": "not_applicable", "reason": "recipient_opted_out" } +/// ``` +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NotifyOutcomeDto { + /// An email actually went out for this recipient. `detail` is + /// `"magic_link"` (external invitation with a fresh token) or + /// `"plain_notification"` (internal "you got a new grant" mail). + Sent { detail: String }, + /// Skipped silently because this (granter, recipient) pair was + /// notified less than the coalesce-window ago. The grant is still + /// recorded; the recipient sees it next time they log in. + Coalesced { + last_sent_at: chrono::DateTime, + }, + /// Per-recipient hard cap (5/h) reached. The caller may retry after + /// `retry_after_secs`. + RateLimited { retry_after_secs: u32 }, + /// No mail was dispatched for this recipient. `reason` is one of: + /// - `"recipient_opted_out"` — user toggled `notify_on_share = false` + /// - `"operator_disabled"` — `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE=false` + /// - `"no_email"` — user row has no email on file + /// - `"oidc_only_no_email"` — OIDC-only user with no email claim + /// - `"subject_is_token"` — anonymous link share (the surface + /// that creates the grant or the `/notify` endpoint maps this to 409) + NotApplicable { reason: String }, +} + +/// The aggregated result of dispatching share notifications for ONE grant +/// action (one `create_grant` request OR one `/notify` call). Carries +/// per-recipient outcomes so the frontend can render a single +/// summary-style toast: +/// +/// - `total_recipients = 1`, `outcomes[0] = Sent` → "Notified Carol" +/// - `total_recipients = 1`, `outcomes[0] = Coalesced` → "Carol already +/// notified recently" +/// - `total_recipients = N`, all `Sent` → "Notified all N group members" +/// - `total_recipients = N`, mix → "Notified 8 of 10 — 2 opted out" +/// +/// `total_recipients` equals `outcomes.len()` after resolution. For +/// token-subject grants it is `0` (no human recipient — no toast). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct NotifyOutcomeSetDto { + pub total_recipients: usize, + pub outcomes: Vec, +} + +impl NotifyOutcomeSetDto { + /// Construct an empty set (token subjects, no recipients to notify). + pub fn empty() -> Self { + Self { + total_recipients: 0, + outcomes: Vec::new(), + } + } + + /// Construct from a list of outcomes, deriving `total_recipients` + /// from the list length. Use this from `RecipientNotificationService` + /// after the per-member loop completes. + pub fn from_outcomes(outcomes: Vec) -> Self { + Self { + total_recipients: outcomes.len(), + outcomes, + } + } +} + +/// Response body for `POST /api/grants`. Wraps the array of created +/// grants (one per `permission` in the request) together with the +/// aggregated notification result. Replaces the previous bare +/// `Vec` shape; the frontend share modal is updated in +/// lockstep. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct CreateGrantResponseDto { + pub grants: Vec, + pub notification: NotifyOutcomeSetDto, +} + // ════════════════════════════════════════════════════════════════════════════ // Shared-with-me DTOs (GET /api/grants/incoming/resources) // ════════════════════════════════════════════════════════════════════════════ @@ -372,6 +464,13 @@ pub struct OutgoingResourceGrantDto { pub expires_at: Option>, /// Whether the token has a password set. Always `false` for user subjects. pub has_password: bool, + /// True when the subject is a magic-link-only external user + /// (PR N2). Always `false` for token and group subjects, and for + /// internal users. Used by the My Shares per-row menu to choose + /// between "Resend invitation email" (external) and "Notify by + /// email" (internal). + #[serde(default)] + pub is_external: bool, } /// One item in the my-shares list. diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index abe91b79..6604f52f 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -53,6 +53,14 @@ pub struct UserDto { /// through `/api/auth/me` and `PATCH /api/auth/me/profile`. #[serde(skip_serializing_if = "Option::is_none")] pub preferred_locale: Option, + /// Whether the user wants an email when someone shares a resource + /// with them. `true` (default) = receive share-notification mails; + /// `false` = grants are still created but no email is sent. Honored + /// only on the plain-notification path — magic-link first-invitations + /// to brand-new external users always send, otherwise the recipient + /// could never claim the share. Round-trips through `/api/auth/me` + /// and `PATCH /api/auth/me/profile`. + pub notify_on_share: bool, } impl From for UserDto { @@ -76,6 +84,7 @@ impl From for UserDto { family_name: user.family_name().map(str::to_string), email_verified_at: user.email_verified_at(), preferred_locale: user.preferred_locale().map(str::to_string), + notify_on_share: user.notify_on_share(), } } } @@ -169,6 +178,13 @@ pub struct UpdateProfileDto { /// normalises `""` → `None`). #[serde(default)] pub preferred_locale: Option, + /// Whether to receive an email when someone shares a resource with + /// the user. Absent → no change (existing setting preserved). Pass + /// `true` to opt in, `false` to opt out. Honored only on the + /// plain-notification path; magic-link first-invitations to externals + /// always send. + #[serde(default)] + pub notify_on_share: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 0672c8a6..57f9782c 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -75,6 +75,12 @@ pub trait UserStoragePort: Send + Sync + 'static { /// Gets a user by ID async fn get_user_by_id(&self, id: Uuid) -> Result; + /// Batch-loads users by id. Order is unspecified; missing ids are + /// silently dropped. Used by group-recipient expansion in + /// `RecipientNotificationService` to avoid N+1 lookups when notifying + /// a group of size N. + async fn get_users_by_ids(&self, ids: Vec) -> Result, DomainError>; + /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> Result; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 8bae16a0..007626b9 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1298,6 +1298,17 @@ impl AuthApplicationService { } } + // ── Share-notification opt-out (PR N1) ─────────────────── + // Boolean field; absent → no change. Idempotent — setting the + // same value twice is fine but doesn't re-emit an audit row + // because `changed` won't pick it up. + if let Some(notify) = dto.notify_on_share + && notify != user.notify_on_share() + { + user.set_notify_on_share(notify); + changed.push("notify_on_share"); + } + if changed.is_empty() { // No-op — return the current user without a DB write. return Ok(UserDto::from(user)); @@ -1320,6 +1331,19 @@ impl AuthApplicationService { self.get_user(user_id).await } + /// Load the full `User` entity for the given id. Unlike + /// `get_user_by_id` this returns the domain entity (not a DTO), so + /// callers can read fields like `notify_on_share()`, + /// `preferred_locale()`, or `is_external()` without round-tripping + /// through the DTO shape. Used by `grant_handler::create_grant` to + /// hand the granter entity to `RecipientNotificationService`. + pub async fn get_user_entity( + &self, + user_id: Uuid, + ) -> Result { + UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await + } + /// Visibility-checked profile lookup for `GET /api/users/{id}`. /// /// Returns `NotFound` (not `AccessDenied`) when the caller has no diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index bb7bd816..5c064b32 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -268,14 +268,17 @@ impl MagicLinkInviteService { /// invitation link. Caller is expected to have already created the /// grant rows. /// - /// `inviter_username` is interpolated into the subject line as a - /// trust signal ("Alice shared with you on OxiCloud"). The message - /// body is plain text only in v1; HTML templating is out of scope - /// (see plan "Out of scope" → "Email template engine"). + /// `inviter` is interpolated into the subject line ("Alice shared + /// with you on OxiCloud") and body ("Alice shared a + /// folder with you. Open it by..."). Two forms are computed via + /// [`User::display_full`] — the short form goes into the subject + /// (keeps inbox-row width sane), the email-decorated form goes + /// into the body where the extra identifier helps the recipient + /// place who's reaching out. pub async fn issue_invitation( &self, recipient: &User, - inviter_username: &str, + inviter: &User, resource: Resource, ) -> Result<(), DomainError> { // The grant is in place either way; only mint a magic link when @@ -335,8 +338,16 @@ impl MagicLinkInviteService { let locale = self.locale_for(recipient); let kind_label = self.i18n_or(kind_key, &locale, &[]).await; let ttl_hours = self.magic_link_cfg.invite_ttl_hours.to_string(); + // Two display forms: `inviter` (short, no email) flows into the + // subject line; `inviter_full` (with email decoration) flows + // into the body. Templates pick whichever placeholder they + // want — see static/locales/en.json `server.magic_link.email. + // invitation.*` for the canonical references. + let inviter_short = inviter.display_full(false); + let inviter_full = inviter.display_full(true); let invite_args: Vec<(&str, &str)> = vec![ - ("inviter", inviter_username), + ("inviter", inviter_short.as_str()), + ("inviter_full", inviter_full.as_str()), ("kind", &kind_label), ("link", &link), ("ttl_hours", &ttl_hours), diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 59e938f2..7fa82755 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -20,6 +20,7 @@ pub mod music_service; pub mod nextcloud_file_id_service; pub mod nextcloud_login_flow_service; pub mod recent_service; +pub mod recipient_notification_service; pub mod search_service; pub mod share_browse_service; pub mod share_service; diff --git a/src/application/services/recipient_notification_service.rs b/src/application/services/recipient_notification_service.rs new file mode 100644 index 00000000..fce62b27 --- /dev/null +++ b/src/application/services/recipient_notification_service.rs @@ -0,0 +1,680 @@ +//! Unified entry point for share-related notification emails. +//! +//! Single service called by both `POST /api/grants` (initial invitation +//! when a grant lands) and `POST /api/grants/{id}/notify` (manual resend +//! from the My Shares menu). Replaces the prior arrangement where +//! `create_grant` directly invoked +//! [`MagicLinkInviteService::issue_invitation`] and internal users got +//! no email at all. +//! +//! # Behaviour ladder +//! +//! Per resolved recipient member: +//! +//! 1. **Eligibility** decides the dispatch arm: +//! - `magic_link_eligibility(recipient) == Allow` → +//! `NotifyKind::MagicLink` (mints a token and emails the +//! invitation by delegating to +//! [`MagicLinkInviteService::issue_invitation`]). +//! - Otherwise (password user, OIDC user, OIDC-linked external) → +//! `NotifyKind::PlainNotification` — provided the recipient has +//! not opted out (`auth.users.notify_on_share = false`) and the +//! operator-level kill switch +//! `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` is `true`. +//! - Otherwise → `NotifyOutcome::NotApplicable` with a structured +//! reason. +//! 2. **Coalesce check** keyed by `(granter_id, recipient_email)`. If +//! the last send for this pair was less than the window ago, return +//! `Coalesced` without dispatching. Magic-link first-invitations +//! are NOT coalesced — they're the only way the recipient can claim +//! the share. +//! 3. **Hard rate limit** keyed by recipient email. Reuses +//! `magic_link_send_per_email_rate_limiter` so an attacker can't +//! alternate between `/notify` and `/magic/v1/{token}/resend` to +//! double the cap. +//! 4. **Dispatch** via the magic-link arm or the plain-notification +//! arm. On successful SMTP send, update the coalesce timestamp. +//! 5. **Audit**: one `grant.notify_sent` or `grant.notify_skipped` per +//! member; for group sends, one `grant.notify_group_expanded` +//! summary line carrying `group_id` and `member_count`. +//! +//! # Forward-compatibility +//! +//! The entry takes `(granter, subject, resource, trigger)` — NOT a +//! pre-resolved `&User` — so [`Subject::Group`] is a real arm in +//! [`Self::resolve_subject_members`] and not a future refactor. The +//! infrastructure (group repository, transitive expansion with 30s +//! Moka cache) already ships from earlier work; we just plug in. + +use std::sync::Arc; +use std::time::Duration; + +use askama::Template; +use chrono::{DateTime, Utc}; +use moka::sync::Cache; +use uuid::Uuid; + +use crate::application::dtos::grant_dto::{NotifyOutcomeDto, NotifyOutcomeSetDto}; +use crate::application::ports::email_sender::{EmailMessage, EmailSender}; +use crate::application::services::i18n_application_service::I18nApplicationService; +use crate::application::services::magic_link_invite_service::{ + Eligibility, MagicLinkInviteService, magic_link_eligibility, +}; +use crate::application::services::subject_group_service::SubjectGroupService; +use crate::common::config::MagicLinkConfig; +use crate::common::errors::DomainError; +use crate::common::locale::{Locale, LocaleRegistry}; +use crate::domain::entities::user::User; +use crate::domain::repositories::user_repository::UserRepository; +use crate::domain::services::authorization::{Resource, Subject}; +use crate::infrastructure::repositories::pg::UserPgRepository; +use crate::interfaces::middleware::rate_limit::RateLimiter; + +/// What triggered the notification — purely an audit discriminator. +/// `GrantCreated` → fired implicitly when a grant lands; `ManualResend` +/// → granter explicitly clicked "Notify by email" in My Shares. +#[derive(Debug, Clone, Copy)] +pub enum NotifyTrigger { + GrantCreated, + ManualResend, +} + +impl NotifyTrigger { + fn audit_str(self) -> &'static str { + match self { + NotifyTrigger::GrantCreated => "grant_created", + NotifyTrigger::ManualResend => "manual_resend", + } + } +} + +/// Which email arm dispatched. `MagicLink` carries a one-shot token in +/// the URL; `PlainNotification` carries only a `/login` deep link. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotifyKind { + MagicLink, + PlainNotification, +} + +impl NotifyKind { + fn audit_str(self) -> &'static str { + match self { + NotifyKind::MagicLink => "magic_link", + NotifyKind::PlainNotification => "plain_notification", + } + } +} + +/// One per resolved recipient. The variant names are stable audit-log +/// values — log aggregators key off them; do not rename or repurpose. +#[derive(Debug, Clone)] +pub enum NotifyOutcome { + /// SMTP send succeeded for this recipient. + Sent { kind: NotifyKind }, + /// Skipped because the same (granter, recipient) pair was notified + /// less than the coalesce window ago. The grant is recorded; the + /// recipient sees it at next login. Carries the last-send timestamp + /// so the frontend can format an informative toast. + Coalesced { last_sent_at: DateTime }, + /// Per-recipient hard cap reached. Caller may retry after the + /// returned number of seconds. + RateLimited { retry_after_secs: u32 }, + /// No mail dispatched. `reason` is a stable enum-style key: + /// `recipient_opted_out`, `operator_disabled`, `no_email`, + /// `account_inactive`, `subject_is_token`. + NotApplicable { reason: &'static str }, +} + +impl NotifyOutcome { + fn to_dto(&self) -> NotifyOutcomeDto { + match self { + NotifyOutcome::Sent { kind } => NotifyOutcomeDto::Sent { + detail: kind.audit_str().to_string(), + }, + NotifyOutcome::Coalesced { last_sent_at } => NotifyOutcomeDto::Coalesced { + last_sent_at: *last_sent_at, + }, + NotifyOutcome::RateLimited { retry_after_secs } => NotifyOutcomeDto::RateLimited { + retry_after_secs: *retry_after_secs, + }, + NotifyOutcome::NotApplicable { reason } => NotifyOutcomeDto::NotApplicable { + reason: (*reason).to_string(), + }, + } + } +} + +/// Aggregated result for one share-notification action. Carries one +/// outcome per resolved recipient (1 for user subjects, 0 for token +/// subjects, N for group subjects). +#[derive(Debug, Clone)] +pub struct NotifyOutcomeSet { + pub outcomes: Vec, +} + +impl NotifyOutcomeSet { + pub fn empty() -> Self { + Self { + outcomes: Vec::new(), + } + } + + pub fn total_recipients(&self) -> usize { + self.outcomes.len() + } + + pub fn to_dto(&self) -> NotifyOutcomeSetDto { + NotifyOutcomeSetDto::from_outcomes( + self.outcomes.iter().map(NotifyOutcome::to_dto).collect(), + ) + } +} + +/// Default coalesce window (10 minutes). Bursts of share creations to +/// the same recipient inside this window produce ONE email; subsequent +/// shares are coalesced silently. Recipient still sees every share at +/// next login. +const COALESCE_WINDOW_SECS: u64 = 10 * 60; + +/// Maximum keys held by the coalesce cache. Way above any realistic +/// per-tenant burst; bounded to keep memory predictable. +const COALESCE_CACHE_MAX_ENTRIES: u64 = 100_000; + +pub struct RecipientNotificationService { + user_storage: Arc, + magic_link_service: Arc, + email_sender: Arc, + i18n: Arc, + locale_registry: Arc, + subject_groups: Arc, + /// Per-(granter, recipient_email) timestamp of last successful send. + /// Sliding window — read+rewrite resets the TTL but that's fine + /// because we only insert on actual sends. + coalesce_cache: Cache<(Uuid, String), DateTime>, + /// Shared with the public `/magic/v1/{token}/resend` channel so an + /// attacker can't alternate between channels to double the cap. + per_email_limiter: Arc, + magic_link_cfg: MagicLinkConfig, + public_base_url: String, +} + +impl RecipientNotificationService { + #[allow(clippy::too_many_arguments)] + pub fn new( + user_storage: Arc, + magic_link_service: Arc, + email_sender: Arc, + i18n: Arc, + locale_registry: Arc, + subject_groups: Arc, + per_email_limiter: Arc, + magic_link_cfg: MagicLinkConfig, + public_base_url: String, + ) -> Self { + let coalesce_cache = Cache::builder() + .time_to_live(Duration::from_secs(COALESCE_WINDOW_SECS)) + .max_capacity(COALESCE_CACHE_MAX_ENTRIES) + .build(); + Self { + user_storage, + magic_link_service, + email_sender, + i18n, + locale_registry, + subject_groups, + coalesce_cache, + per_email_limiter, + magic_link_cfg, + public_base_url, + } + } + + /// Single entry point. Called by `create_grant` after grant rows are + /// persisted, and by `notify_grant_recipient` after loading the + /// grant by id. Returns one outcome per resolved recipient. + /// + /// Errors here are *infrastructure* errors (DB unreachable while + /// expanding a group, etc.). Per-recipient failures (SMTP, etc.) + /// are captured as outcomes, never as `Err`. + pub async fn send_share_notification( + &self, + granter: &User, + subject: Subject, + resource: Resource, + trigger: NotifyTrigger, + ) -> Result { + // Resolve subject → Vec. Token subjects yield an empty + // vec; the calling handler maps that to its own response. + let members = self.resolve_subject_members(subject).await?; + if members.is_empty() { + return Ok(NotifyOutcomeSet::empty()); + } + + // Audit summary line for group expansions — operators tracing + // a single grant action want to see "this fanned out to N + // recipients" without combing per-member lines. + if let Subject::Group(group_id) = subject { + tracing::info!( + target: "audit", + event = "grant.notify_group_expanded", + granter_id = %granter.id(), + group_id = %group_id, + member_count = members.len(), + resource = ?resource, + trigger = %trigger.audit_str(), + "📣 group {} expanded to {} member(s) for notification", + group_id, + members.len(), + ); + } + + let mut outcomes = Vec::with_capacity(members.len()); + for member in &members { + let outcome = self + .dispatch_to_one_user(granter, member, resource, trigger) + .await; + outcomes.push(outcome); + } + Ok(NotifyOutcomeSet { outcomes }) + } + + /// User subjects → single-element vec; Token subjects → empty; + /// Group subjects → transitively expanded member list. + async fn resolve_subject_members(&self, subject: Subject) -> Result, DomainError> { + match subject { + Subject::User(id) => { + match UserRepository::get_user_by_id(&*self.user_storage, id).await { + Ok(user) => Ok(vec![user]), + Err(e) => Err(DomainError::from(e)), + } + } + Subject::Token(_) => Ok(Vec::new()), + Subject::Group(group_id) => { + let member_ids = self.subject_groups.list_transitive_users(group_id).await?; + if member_ids.is_empty() { + return Ok(Vec::new()); + } + UserRepository::get_users_by_ids(&*self.user_storage, member_ids) + .await + .map_err(DomainError::from) + } + } + } + + /// THE last function sending email. Per-recipient: eligibility + /// match → coalesce → rate-limit → dispatch → audit. No SMTP send + /// happens outside this function. + async fn dispatch_to_one_user( + &self, + granter: &User, + recipient: &User, + resource: Resource, + trigger: NotifyTrigger, + ) -> NotifyOutcome { + // 1. Account state — deactivated users get no mail regardless. + if !recipient.is_active() { + self.audit_skipped(granter, recipient, resource, trigger, "account_inactive"); + return NotifyOutcome::NotApplicable { + reason: "account_inactive", + }; + } + + // 2. Choose the dispatch arm. + let kind = + match magic_link_eligibility(recipient, self.magic_link_cfg.open_to_password_users) { + Eligibility::Allow => NotifyKind::MagicLink, + Eligibility::Reject(_) => { + // Plain-notification arm. Check the two gates. + if !self.magic_link_cfg.notify_internal_users_on_share { + self.audit_skipped( + granter, + recipient, + resource, + trigger, + "operator_disabled", + ); + return NotifyOutcome::NotApplicable { + reason: "operator_disabled", + }; + } + if !recipient.notify_on_share() { + self.audit_skipped( + granter, + recipient, + resource, + trigger, + "recipient_opted_out", + ); + return NotifyOutcome::NotApplicable { + reason: "recipient_opted_out", + }; + } + if recipient.email().is_empty() { + self.audit_skipped(granter, recipient, resource, trigger, "no_email"); + return NotifyOutcome::NotApplicable { reason: "no_email" }; + } + NotifyKind::PlainNotification + } + }; + + // 3. Coalesce check — only meaningful when we'd actually send. + // Per-pair: `(granter_id, recipient_email)`. + let coalesce_key = (granter.id(), recipient.email().to_string()); + if let Some(last) = self.coalesce_cache.get(&coalesce_key) { + self.audit_skipped(granter, recipient, resource, trigger, "coalesced"); + return NotifyOutcome::Coalesced { last_sent_at: last }; + } + + // 4. Hard rate limit on the recipient email. + if self + .per_email_limiter + .check_and_increment(recipient.email()) + .is_err() + { + self.audit_skipped(granter, recipient, resource, trigger, "rate_limited"); + return NotifyOutcome::RateLimited { + retry_after_secs: self.per_email_limiter.retry_after() as u32, + }; + } + + // 5. Dispatch + audit. + let send_result = match kind { + NotifyKind::MagicLink => { + // Delegates token mint + locale-resolved bilingual email + // + per-mail audit to the existing service. Its own + // eligibility short-circuit is moot here — we've already + // routed only Accept-eligible recipients to this arm. + // Pass the granter as a `&User` so the inner service can + // compute both the short (subject) and full (body) + // display forms via `display_full(bool)`. + self.magic_link_service + .issue_invitation(recipient, granter, resource) + .await + .map_err(|e| e.message) + } + NotifyKind::PlainNotification => { + self.send_plain_notification(granter, recipient, resource) + .await + } + }; + + match send_result { + Ok(()) => { + // Update coalesce timestamp ONLY on successful send. + // Skipping a coalesce-window-ago send means the next + // attempt re-checks against the same old timestamp, but + // moka's insert resets the TTL anyway — so the window + // effectively slides forward on each successful send. + self.coalesce_cache.insert(coalesce_key, Utc::now()); + tracing::info!( + target: "audit", + event = "grant.notify_sent", + kind = %kind.audit_str(), + granter_id = %granter.id(), + recipient_id = %recipient.id(), + recipient_email = %recipient.email(), + resource = ?resource, + trigger = %trigger.audit_str(), + "📨 notify sent ({}) to {}", + kind.audit_str(), + recipient.email(), + ); + NotifyOutcome::Sent { kind } + } + Err(err) => { + // The grant landed; SMTP failure is non-fatal. Mirror + // the long-standing magic-link policy: warn-log, return + // a Sent-shaped outcome anyway (the operator sees the + // truth in the audit row; the caller's UI is just less + // useful for a few seconds). + tracing::warn!( + target: "audit", + event = "grant.notify_send_failed", + kind = %kind.audit_str(), + granter_id = %granter.id(), + recipient_id = %recipient.id(), + recipient_email = %recipient.email(), + error = %err, + "📭 notify send failed ({}): {}", + kind.audit_str(), + err, + ); + // Don't bump coalesce on failure — we want the next + // legitimate attempt to retry. + NotifyOutcome::Sent { kind } + } + } + } + + /// Render and send the plain-notification email ("Hey, you got a + /// new grant"). No magic link; recipient must sign in normally. + async fn send_plain_notification( + &self, + granter: &User, + recipient: &User, + resource: Resource, + ) -> Result<(), String> { + let locale = self.locale_for(recipient); + let kind_key = match resource { + Resource::Folder(_) => "server.magic_link.email.kind_folder", + Resource::File(_) => "server.magic_link.email.kind_file", + }; + let kind_label = self.i18n_or(kind_key, &locale, &[]).await; + // Short form for the subject, long form (with email) for the + // body — same pattern as `MagicLinkInviteService::issue_invitation`. + let inviter_short = granter.display_full(false); + let inviter_full = granter.display_full(true); + let login_link = format!("{}/#/login", self.public_base_url.trim_end_matches('/'),); + + let args: Vec<(&str, &str)> = vec![ + ("inviter", inviter_short.as_str()), + ("inviter_full", inviter_full.as_str()), + ("kind", &kind_label), + ("login_link", &login_link), + ]; + + let subject = self + .i18n_or("server.notification.share.subject", &locale, &args) + .await; + let body = self + .render_bilingual("server.notification.share.body", &locale, &args) + .await; + + let message = EmailMessage { + to: recipient.email().to_string(), + subject, + text_body: body, + html_body: None, + }; + + self.email_sender + .send(message) + .await + .map(|_| ()) + .map_err(|e| e.message) + } + + /// Resolve a recipient's stored locale → `Locale`. Mirrors + /// `MagicLinkInviteService::locale_for`: bad/unknown codes fall back + /// to the server default. + fn locale_for(&self, user: &User) -> Locale { + user.preferred_locale() + .and_then(|code| self.locale_registry.parse(code)) + .unwrap_or_else(|| self.locale_registry.default_locale().clone()) + } + + /// Translate with arg substitution, falling back to the literal key + /// if the i18n lookup errors (defensive — shouldn't happen with the + /// English-fallback layer in place). + async fn i18n_or(&self, key: &str, locale: &Locale, args: &[(&str, &str)]) -> String { + self.i18n + .translate_args(key, Some(locale.clone()), args) + .await + .unwrap_or_else(|_| key.to_string()) + } + + /// Body + English-fallback partial. Same shape as + /// `MagicLinkInviteService::render_bilingual`. Could be lifted into + /// a shared helper later — kept duplicated for now because there + /// are only two call sites. + async fn render_bilingual( + &self, + body_key: &str, + locale: &Locale, + args: &[(&str, &str)], + ) -> String { + let body = self.i18n_or(body_key, locale, args).await; + let english_fallback = if locale.is_english() { + None + } else { + Some(self.i18n_or(body_key, &Locale::english(), args).await) + }; + let divider = self + .i18n_or( + "server.magic_link.email.english_fallback_divider", + locale, + &[], + ) + .await; + let template = BilingualBody { + body: body.clone(), + divider, + english_fallback, + }; + template.render().unwrap_or(body) + } + + fn audit_skipped( + &self, + granter: &User, + recipient: &User, + resource: Resource, + trigger: NotifyTrigger, + reason: &'static str, + ) { + tracing::info!( + target: "audit", + event = "grant.notify_skipped", + reason = reason, + granter_id = %granter.id(), + recipient_id = %recipient.id(), + recipient_email = %recipient.email(), + resource = ?resource, + trigger = %trigger.audit_str(), + "🤫 notify skipped ({}) for {}", + reason, + recipient.email(), + ); + } +} + +/// Reuses the same partial template as `MagicLinkInviteService`. The +/// duplication is intentional: askama derive macros need a struct per +/// callsite, and pulling the rendering struct out of the magic-link +/// module would create a fan-out of dependencies. Two ~10-line copies +/// is cheaper than the abstraction. +#[derive(Template)] +#[template(path = "magic_link/email_body.txt")] +struct BilingualBody { + body: String, + divider: String, + english_fallback: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notify_outcome_to_dto_sent_variants() { + let dto_ml = NotifyOutcome::Sent { + kind: NotifyKind::MagicLink, + } + .to_dto(); + let dto_pn = NotifyOutcome::Sent { + kind: NotifyKind::PlainNotification, + } + .to_dto(); + match dto_ml { + NotifyOutcomeDto::Sent { detail } => assert_eq!(detail, "magic_link"), + _ => panic!("expected Sent"), + } + match dto_pn { + NotifyOutcomeDto::Sent { detail } => assert_eq!(detail, "plain_notification"), + _ => panic!("expected Sent"), + } + } + + #[test] + fn notify_outcome_to_dto_skip_variants() { + let now = Utc::now(); + match (NotifyOutcome::Coalesced { last_sent_at: now }).to_dto() { + NotifyOutcomeDto::Coalesced { last_sent_at } => assert_eq!(last_sent_at, now), + _ => panic!("expected Coalesced"), + } + match (NotifyOutcome::RateLimited { + retry_after_secs: 3600, + }) + .to_dto() + { + NotifyOutcomeDto::RateLimited { retry_after_secs } => { + assert_eq!(retry_after_secs, 3600) + } + _ => panic!("expected RateLimited"), + } + match (NotifyOutcome::NotApplicable { + reason: "recipient_opted_out", + }) + .to_dto() + { + NotifyOutcomeDto::NotApplicable { reason } => { + assert_eq!(reason, "recipient_opted_out") + } + _ => panic!("expected NotApplicable"), + } + } + + #[test] + fn notify_outcome_set_total_recipients_matches_outcomes_len() { + let set = NotifyOutcomeSet { + outcomes: vec![ + NotifyOutcome::Sent { + kind: NotifyKind::PlainNotification, + }, + NotifyOutcome::Coalesced { + last_sent_at: Utc::now(), + }, + NotifyOutcome::NotApplicable { + reason: "recipient_opted_out", + }, + ], + }; + assert_eq!(set.total_recipients(), 3); + let dto = set.to_dto(); + assert_eq!(dto.total_recipients, 3); + assert_eq!(dto.outcomes.len(), 3); + } + + #[test] + fn empty_outcome_set() { + let set = NotifyOutcomeSet::empty(); + assert_eq!(set.total_recipients(), 0); + let dto = set.to_dto(); + assert_eq!(dto.total_recipients, 0); + assert!(dto.outcomes.is_empty()); + } + + #[test] + fn audit_strs_are_stable() { + // These string values appear in operator-facing audit logs and + // log aggregators key off them. A rename here is a breaking + // change to dashboards — guard against accidental drift. + assert_eq!(NotifyTrigger::GrantCreated.audit_str(), "grant_created"); + assert_eq!(NotifyTrigger::ManualResend.audit_str(), "manual_resend"); + assert_eq!(NotifyKind::MagicLink.audit_str(), "magic_link"); + assert_eq!( + NotifyKind::PlainNotification.audit_str(), + "plain_notification" + ); + } +} diff --git a/src/common/config.rs b/src/common/config.rs index 3ce797cb..5a3963f9 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -761,6 +761,20 @@ pub struct MagicLinkConfig { /// may enforce MFA we shouldn't bypass. See /// `magic_link_eligibility()` for the precedence ladder. pub open_to_password_users: bool, + /// Operator-level kill switch for plain-notification emails to + /// internal users (PR N1). When `true` (default), users who can't + /// receive a magic link (password users, OIDC users) get a "Hey, + /// you got a new grant" mail with a `/login` deep link on every + /// share. When `false`, the plain-notification arm is suppressed + /// entirely — internal users discover shares only on next login. + /// + /// This is a coarser knob than the per-user + /// `auth.users.notify_on_share` column: when this is `false`, the + /// user-level opt-in does not matter. External-user magic-link + /// invitations are NOT affected by this flag — those always send, + /// because the link is the only way the recipient can claim the + /// share for the first time. + pub notify_internal_users_on_share: bool, } impl Default for MagicLinkConfig { @@ -774,6 +788,7 @@ impl Default for MagicLinkConfig { send_per_email_per_hour: 5, send_per_ip_per_hour: 200, open_to_password_users: false, + notify_internal_users_on_share: true, } } } @@ -1475,6 +1490,9 @@ impl AppConfig { if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") { config.magic_link.open_to_password_users = v == "true" || v == "1"; } + if let Ok(v) = env::var("OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE") { + config.magic_link.notify_internal_users_on_share = v == "true" || v == "1"; + } if let Ok(v) = env::var("OXICLOUD_DEFAULT_LOCALE") { let trimmed = v.trim(); diff --git a/src/common/di.rs b/src/common/di.rs index bac3b96e..6a7a0e71 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -948,9 +948,10 @@ impl AppServiceFactory { ), ), )), - email_sender: None, // populated below - mock_email_sender: None, // populated below - magic_link_invite_service: None, // populated below + email_sender: None, // populated below + mock_email_sender: None, // populated below + magic_link_invite_service: None, // populated below + recipient_notification_service: None, // populated below alongside magic_link_invite_service // 60 lookups / minute / caller; cap at 50 000 tracked // callers to bound memory. The same limiter instance is // shared by every clone of AppState since it lives in an @@ -1013,9 +1014,9 @@ impl AppServiceFactory { ); app_state.magic_link_invite_service = Some(Arc::new( crate::application::services::magic_link_invite_service::MagicLinkInviteService::new( - invite_user_storage, + invite_user_storage.clone(), invite_magic_link_repo, - email_sender, + email_sender.clone(), lifecycle, app_state.applications.i18n_service.clone(), app_state.locale_registry.clone(), @@ -1023,6 +1024,30 @@ impl AppServiceFactory { self.config.base_url(), ), )); + + // PR N1: wire the unified RecipientNotificationService. + // Only constructed when MagicLinkInviteService is also + // available — the magic-link path delegates to it. + // SubjectGroupService is built earlier in this factory; the + // notification service needs it for the Group subject arm. + if let (Some(magic_link_svc), Some(subject_groups)) = ( + app_state.magic_link_invite_service.clone(), + app_state.subject_group_service.clone(), + ) { + app_state.recipient_notification_service = Some(Arc::new( + crate::application::services::recipient_notification_service::RecipientNotificationService::new( + invite_user_storage, + magic_link_svc, + email_sender, + app_state.applications.i18n_service.clone(), + app_state.locale_registry.clone(), + subject_groups, + app_state.magic_link_send_per_email_rate_limiter.clone(), + self.config.magic_link.clone(), + self.config.base_url(), + ), + )); + } } // 9b. Wire admin settings service when auth is available @@ -1374,6 +1399,15 @@ pub struct AppState { pub magic_link_invite_service: Option< Arc, >, + /// Unified share-notification dispatcher (PR N1) — used by both + /// `create_grant` and the future `POST /api/grants/{id}/notify` to + /// route share emails through coalesce + rate-limit + per-recipient + /// dispatch. `None` when SMTP / magic-link / subject-group services + /// aren't all configured; callers degrade to silent no-op in that + /// case (no mail sent, grant still created). + pub recipient_notification_service: Option< + Arc, + >, /// Per-caller sliding-window limiter for `GET /api/users/{id}`. The /// endpoint's primary defense is the visibility check, but a stale /// JWT could in theory iterate UUIDs against the related-by-grant diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index b4c8806d..22417cd8 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -85,6 +85,16 @@ pub struct User { /// application layer is the authoritative gatekeeper against the /// `LocaleRegistry`. preferred_locale: Option, + /// Per-user opt-out for share-notification emails (PR N1). TRUE = + /// receive a mail when someone grants access to a resource (default); + /// FALSE = grant still recorded but `RecipientNotificationService` + /// returns `NotApplicable { recipient_opted_out }` and no mail is + /// sent. Bypassed for magic-link first-invitations to external users + /// — the link is their only way to claim the share, so suppressing + /// it would lock them out. Once an external becomes a real account + /// and opts out, subsequent shares from other granters honor the + /// flag. + notify_on_share: bool, } impl User { @@ -178,6 +188,11 @@ impl User { // language switcher, or invitation-time inheritance fill // this in later. NULL resolves to OXICLOUD_DEFAULT_LOCALE. preferred_locale: None, + // PR N1: default to opted-in. The profile checkbox is the + // user-facing toggle; the column default in + // `users_notify_on_share` mirrors this for rows reconstructed + // from disk without going through `new`. + notify_on_share: true, }) } @@ -221,6 +236,7 @@ impl User { family_name: None, email_verified_at: None, preferred_locale: None, + notify_on_share: true, } } @@ -245,6 +261,7 @@ impl User { family_name: Option, email_verified_at: Option>, preferred_locale: Option, + notify_on_share: bool, ) -> Self { Self { id, @@ -266,6 +283,7 @@ impl User { family_name, email_verified_at, preferred_locale, + notify_on_share, } } @@ -341,6 +359,64 @@ impl User { } } + /// Rich, user-facing display label for notification surfaces + /// (transactional emails, share invitations, "Alice + /// shared X with you" — anywhere a human is reading the line). + /// + /// `with_email` controls whether the address is appended as + /// `" "` after the name part: + /// - `true` — best for the email **body** ("Alice Smith + /// shared a folder with you"), where the + /// extra identifier is helpful at a glance. + /// - `false` — best for the **subject line** and other compact + /// contexts where dragging the email into a 80-char inbox row + /// would be noise ("Alice Smith shared a folder with you"). + /// + /// Priority order (mirrors RFC 5322 display-name conventions). The + /// `` decoration in cases 1 and 3 is omitted when + /// `with_email` is false: + /// + /// 1. `"Given Family"` (+ ` `) — full name; the most + /// informative form. + /// 2. `"username"` (+ ` `) — handle; the typical case + /// for password / OIDC users without first/last claims. + /// 3. `email` — last-resort fallback. The + /// raw email address is always present for non-OCM users and is + /// the unambiguous identifier. Returned regardless of + /// `with_email` since it IS the label here. + /// 4. shortened UUID — failure mode (no email, + /// no username, no given/family — shouldn't happen with current + /// schema invariants but kept defensive for OCM-federated rows). + /// + /// External users provisioned via magic-link typically have only an + /// email and fall through to branch 3. Internal users with OIDC + /// JIT often have given/family from the IdP claims → branch 1. + /// Sister of [`Self::display_for_audit`], which deliberately + /// returns a *less* identifying label for log lines. + pub fn display_full(&self, with_email: bool) -> String { + let g = self.given_name.as_deref(); + let f = self.family_name.as_deref(); + let u = self.username.as_deref(); + let has_email = !self.email.is_empty(); + + if let (Some(g), Some(f)) = (g, f) { + if with_email && has_email { + return format!("{} {} <{}>", g, f, self.email); + } + return format!("{} {}", g, f); + } + if let Some(u) = u { + if with_email && has_email { + return format!("{} <{}>", u, self.email); + } + return u.to_string(); + } + if has_email { + return self.email.clone(); + } + format!("{}…", &self.id.to_string()[..8]) + } + pub fn oidc_provider(&self) -> Option<&str> { self.oidc_provider.as_deref() } @@ -430,6 +506,24 @@ impl User { self.updated_at = Utc::now(); } + /// Whether this user wants to receive an email when someone grants + /// them access to a resource. `RecipientNotificationService` checks + /// this on the plain-notification arm; magic-link first-invitations + /// to external users bypass it (otherwise the recipient could never + /// claim the share). Defaults TRUE for both the entity constructor + /// and the schema column. + pub fn notify_on_share(&self) -> bool { + self.notify_on_share + } + + /// Flip the share-notification preference. The caller is expected + /// to have already validated input shape (the field is a boolean, + /// so there is no work beyond storage). Bumps `updated_at`. + pub fn set_notify_on_share(&mut self, notify: bool) { + self.notify_on_share = notify; + self.updated_at = Utc::now(); + } + /// Claim or change the username. Runs the same validation as the /// constructor — callers must still ensure uniqueness at the repo /// level. Bumps `updated_at`. Used by the post-create profile-edit @@ -584,3 +678,93 @@ impl User { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn build_user( + username: Option<&str>, + given: Option<&str>, + family: Option<&str>, + email: &str, + ) -> User { + User::from_data_full( + Uuid::new_v4(), + username.map(str::to_string), + email.to_string(), + None, + UserRole::User, + 0, + 0, + Utc::now(), + Utc::now(), + None, + true, + None, + None, + None, + false, + given.map(str::to_string), + family.map(str::to_string), + None, + None, + true, + ) + } + + #[test] + fn display_full_given_family_with_email() { + let u = build_user(Some("alice"), Some("Alice"), Some("Smith"), "alice@x.com"); + assert_eq!(u.display_full(true), "Alice Smith "); + assert_eq!(u.display_full(false), "Alice Smith"); + } + + #[test] + fn display_full_given_family_takes_priority_over_username() { + // Even when the username is set, the full name is more informative + // and wins. The username surfaces only as part of the address. + let u = build_user(Some("admin"), Some("Bob"), Some("Jones"), "bob@x.com"); + assert_eq!(u.display_full(true), "Bob Jones "); + assert_eq!(u.display_full(false), "Bob Jones"); + } + + #[test] + fn display_full_username_only() { + // The "admin" case the user observed: no given/family on the + // bootstrap admin user. With email → "admin "; + // without → just "admin" (compact form for subject lines). + let u = build_user(Some("admin"), None, None, "admin@x.com"); + assert_eq!(u.display_full(true), "admin "); + assert_eq!(u.display_full(false), "admin"); + } + + #[test] + fn display_full_partial_name_falls_through_to_username() { + // Given without family (or vice versa) is NOT "rich enough" to + // use; we walk to the next priority instead of producing a + // "First " half-name. + let u = build_user(Some("carol"), Some("Carol"), None, "carol@x.com"); + assert_eq!(u.display_full(true), "carol "); + assert_eq!(u.display_full(false), "carol"); + } + + #[test] + fn display_full_email_only() { + // External users provisioned via magic-link typically have no + // username and no given/family — only the email is present. + // `with_email` is moot here: the email IS the label. + let u = build_user(None, None, None, "external@x.com"); + assert_eq!(u.display_full(true), "external@x.com"); + assert_eq!(u.display_full(false), "external@x.com"); + } + + #[test] + fn display_full_partial_name_no_username_falls_to_email() { + // Lone given_name without family AND without username → falls + // all the way through to the raw email. + let u = build_user(None, Some("Solo"), None, "solo@x.com"); + assert_eq!(u.display_full(true), "solo@x.com"); + assert_eq!(u.display_full(false), "solo@x.com"); + } +} diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 81816371..d129f2d1 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -48,6 +48,13 @@ pub trait UserRepository: Send + Sync + 'static { /// Gets a user by ID async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult; + /// Batch-loads a set of users by id, preserving no particular order + /// and silently skipping ids that don't match any row. Caller is + /// responsible for de-duplicating the input vec. Returns an empty + /// vec when given an empty input. Used by group-recipient expansion + /// in `RecipientNotificationService` to avoid N+1 queries. + async fn get_users_by_ids(&self, ids: Vec) -> UserRepositoryResult>; + /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult; diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index ce4ccd77..2e095e59 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -279,6 +279,13 @@ pub struct OutgoingGrantEntry { /// True when the token subject has a password set (`storage.shares.password_hash IS NOT NULL`). /// Always `false` for `user` subjects. pub has_password: bool, + /// True when the user subject is a magic-link-only external user + /// (`auth.users.is_external = TRUE`). Always `false` for token and + /// group subjects, and for internal-user subjects. Surfaced on the + /// My Shares DTO so the frontend's per-row menu can label the + /// notify item "Resend invitation email" (external) vs "Notify by + /// email" (internal). + pub is_external: bool, } /// All subjects that the current user has shared a single resource with, diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 9dcb1df3..c64c7f9a 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -97,10 +97,10 @@ impl UserRepository for UserPgRepository { created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, is_external, given_name, family_name, email_verified_at, - preferred_locale + preferred_locale, notify_on_share ) VALUES ( $1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, $17, $18 + $12, $13, $14, $15, $16, $17, $18, $19 ) RETURNING * "#, @@ -123,6 +123,7 @@ impl UserRepository for UserPgRepository { .bind(user_clone.family_name()) .bind(user_clone.email_verified_at()) .bind(user_clone.preferred_locale()) + .bind(user_clone.notify_on_share()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -147,7 +148,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at, preferred_locale + given_name, family_name, email_verified_at, preferred_locale, notify_on_share FROM auth.users WHERE id = $1 "#, @@ -184,6 +185,7 @@ impl UserRepository for UserPgRepository { row.get("family_name"), row.get("email_verified_at"), row.get("preferred_locale"), + row.get("notify_on_share"), )) } @@ -196,7 +198,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at, preferred_locale + given_name, family_name, email_verified_at, preferred_locale, notify_on_share FROM auth.users WHERE username = $1 "#, @@ -233,6 +235,7 @@ impl UserRepository for UserPgRepository { row.get("family_name"), row.get("email_verified_at"), row.get("preferred_locale"), + row.get("notify_on_share"), )) } @@ -245,7 +248,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at, preferred_locale + given_name, family_name, email_verified_at, preferred_locale, notify_on_share FROM auth.users WHERE email = $1 "#, @@ -282,9 +285,71 @@ impl UserRepository for UserPgRepository { row.get("family_name"), row.get("email_verified_at"), row.get("preferred_locale"), + row.get("notify_on_share"), )) } + /// Batch loads users by id in one query (avoids N+1 for group- + /// recipient expansion). Missing ids are silently skipped — the + /// caller treats absent rows as "no such recipient", same as + /// `get_user_by_id` returning `NotFound` for a single lookup. + async fn get_users_by_ids(&self, ids: Vec) -> UserRepositoryResult> { + if ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query( + r#" + SELECT + id, username, email, password_hash, role::text as role_text, + storage_quota_bytes, storage_used_bytes, + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject, image, is_external, + given_name, family_name, email_verified_at, preferred_locale, notify_on_share + FROM auth.users + WHERE id = ANY($1) + "#, + ) + .bind(&ids) + .fetch_all(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|row| { + let role_str: Option = row.try_get("role_text").unwrap_or(None); + let role = match role_str.as_deref() { + Some("admin") => UserRole::Admin, + _ => UserRole::User, + }; + + User::from_data_full( + row.get("id"), + row.get("username"), + row.get("email"), + row.get("password_hash"), + role, + row.get("storage_quota_bytes"), + row.get("storage_used_bytes"), + row.get("created_at"), + row.get("updated_at"), + row.get("last_login_at"), + row.get("active"), + row.get("oidc_provider"), + row.get("oidc_subject"), + row.get("image"), + row.get("is_external"), + row.get("given_name"), + row.get("family_name"), + row.get("email_verified_at"), + row.get("preferred_locale"), + row.get("notify_on_share"), + ) + }) + .collect()) + } + /// Updates an existing user using a transaction async fn update_user(&self, user: User) -> UserRepositoryResult { // Create a copy of the user for the closure @@ -310,7 +375,8 @@ impl UserRepository for UserPgRepository { given_name = $12, family_name = $13, email_verified_at = $14, - preferred_locale = $15 + preferred_locale = $15, + notify_on_share = $16 WHERE id = $1 "#, ) @@ -329,6 +395,7 @@ impl UserRepository for UserPgRepository { .bind(user_clone.family_name()) .bind(user_clone.email_verified_at()) .bind(user_clone.preferred_locale()) + .bind(user_clone.notify_on_share()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -401,7 +468,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at, preferred_locale + given_name, family_name, email_verified_at, preferred_locale, notify_on_share FROM auth.users WHERE ($3 OR is_external = FALSE) ORDER BY created_at DESC @@ -445,6 +512,7 @@ impl UserRepository for UserPgRepository { row.get("family_name"), row.get("email_verified_at"), row.get("preferred_locale"), + row.get("notify_on_share"), ) }) .collect(); @@ -466,7 +534,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at, preferred_locale + given_name, family_name, email_verified_at, preferred_locale, notify_on_share FROM auth.users WHERE (username ILIKE $1 OR email ILIKE $1) AND ($3 OR is_external = FALSE) @@ -510,6 +578,7 @@ impl UserRepository for UserPgRepository { row.get("family_name"), row.get("email_verified_at"), row.get("preferred_locale"), + row.get("notify_on_share"), ) }) .collect(); @@ -597,7 +666,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at, preferred_locale + given_name, family_name, email_verified_at, preferred_locale, notify_on_share FROM auth.users WHERE role::text = $1 ORDER BY created_at DESC @@ -638,6 +707,7 @@ impl UserRepository for UserPgRepository { row.get("family_name"), row.get("email_verified_at"), row.get("preferred_locale"), + row.get("notify_on_share"), ) }) .collect(); @@ -674,7 +744,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at, preferred_locale + given_name, family_name, email_verified_at, preferred_locale, notify_on_share FROM auth.users WHERE oidc_provider = $1 AND oidc_subject = $2 "#, @@ -711,6 +781,7 @@ impl UserRepository for UserPgRepository { row.get("family_name"), row.get("email_verified_at"), row.get("preferred_locale"), + row.get("notify_on_share"), )) } @@ -792,6 +863,12 @@ impl UserStoragePort for UserPgRepository { .map_err(DomainError::from) } + async fn get_users_by_ids(&self, ids: Vec) -> Result, DomainError> { + UserRepository::get_users_by_ids(self, ids) + .await + .map_err(DomainError::from) + } + async fn get_user_by_username(&self, username: &str) -> Result { UserRepository::get_user_by_username(self, username) .await diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index ca61176c..b86b6357 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -347,6 +347,32 @@ impl PgAclEngine { Ok(Some((res, granter))) } + /// Variant of `find_grant_by_id` that also returns the subject — + /// needed by `POST /api/grants/{id}/notify` to resolve who to email. + /// Returns `(subject, resource, granted_by)` or `None`. + pub async fn find_grant_full_by_id( + &self, + grant_id: Uuid, + ) -> Result, DomainError> { + let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as( + "SELECT subject_type, subject_id, resource_type, resource_id, granted_by \ + FROM storage.access_grants WHERE id = $1", + ) + .bind(grant_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_full_by_id: {e}")))?; + + let Some((st, sid, rt, rid, granter)) = row else { + return Ok(None); + }; + let subject = Subject::from_parts(&st, sid) + .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?; + let resource = Resource::from_parts(&rt, rid) + .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?; + Ok(Some((subject, resource, granter))) + } + /// Row type for all full-grant SELECT queries: /// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at) #[allow(clippy::type_complexity)] @@ -865,6 +891,8 @@ impl AuthorizationEngine for PgAclEngine { // 10 sort_str Option // 11 sort_int Option // 12 has_password bool — token: shares.password_hash IS NOT NULL + // 13 is_external bool — user: auth.users.is_external (PR N2); + // FALSE for token/group subjects. type Row = ( String, Uuid, @@ -879,6 +907,7 @@ impl AuthorizationEngine for PgAclEngine { Option, Option, bool, + bool, ); let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone()); @@ -962,7 +991,8 @@ impl AuthorizationEngine for PgAclEngine { COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, rp.sort_str, rp.sort_int, - (sh.password_hash IS NOT NULL) AS has_password + (sh.password_hash IS NOT NULL) AS has_password, + COALESCE(u.is_external, FALSE) AS is_external FROM rp JOIN storage.access_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id @@ -1025,6 +1055,7 @@ impl AuthorizationEngine for PgAclEngine { ag.subject_id, MAX(COALESCE(u.username, u.email, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display, BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, + COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external, MAX(CASE WHEN ag.subject_type = 'group' THEN 0 WHEN ag.subject_type = 'user' THEN 1 @@ -1066,7 +1097,8 @@ impl AuthorizationEngine for PgAclEngine { ag.permission, LOWER(rp.subject_display) AS sort_str, rp.sort_int, - rp.has_password + rp.has_password, + rp.is_external FROM rp JOIN storage.access_grants ag ON ag.resource_type = rp.resource_type @@ -1110,6 +1142,7 @@ impl AuthorizationEngine for PgAclEngine { ag.subject_id, MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display, BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, + COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external, CASE WHEN BOOL_OR(ag.permission = 'delete') AND BOOL_OR(ag.permission = 'share') THEN 0 @@ -1150,7 +1183,8 @@ impl AuthorizationEngine for PgAclEngine { ag.permission, LOWER(rp.subject_display) AS sort_str, rp.sort_int, - rp.has_password + rp.has_password, + rp.is_external FROM rp JOIN storage.access_grants ag ON ag.resource_type = rp.resource_type @@ -1199,7 +1233,8 @@ impl AuthorizationEngine for PgAclEngine { COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, NULL::text AS sort_str, NULL::bigint AS sort_int, - (sh.password_hash IS NOT NULL) AS has_password + (sh.password_hash IS NOT NULL) AS has_password, + COALESCE(u.is_external, FALSE) AS is_external FROM rp JOIN storage.access_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id @@ -1283,6 +1318,7 @@ impl AuthorizationEngine for PgAclEngine { _, _, has_password, + is_external, ) = r; let Some(resource_type) = ResourceKind::parse(&rt_str) else { continue; @@ -1303,6 +1339,7 @@ impl AuthorizationEngine for PgAclEngine { granted_at, expires_at, has_password, + is_external, }, ) }); @@ -1399,6 +1436,7 @@ impl AuthorizationEngine for PgAclEngine { _, _, has_password, + is_external, ) = r; let Some(resource_type) = ResourceKind::parse(&rt_str) else { continue; @@ -1425,6 +1463,7 @@ impl AuthorizationEngine for PgAclEngine { granted_at, expires_at, has_password, + is_external, }); if !entry.permissions.contains(&perm) { entry.permissions.push(perm); diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index cbb065b2..adc8c5c3 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -20,14 +20,15 @@ use uuid::Uuid; use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::grant_dto::{ - CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto, - PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto, - SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, SubjectInputDto, UpdateRoleDto, - role_from_permissions, + CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto, + OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto, + ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, + SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::folder_ports::FolderUseCase; +use crate::application::services::recipient_notification_service::NotifyTrigger; use crate::common::di::AppState; #[allow(unused_imports)] use crate::common::errors::DomainError; @@ -50,7 +51,7 @@ type AppStateRef = Arc; path = "/api/grants", request_body = CreateGrantDto, responses( - (status = 201, description = "Grant(s) created", body = Vec), + (status = 201, description = "Grant(s) created", body = CreateGrantResponseDto), (status = 400, description = "Invalid input (both/neither of permissions+role provided)"), (status = 404, description = "Resource not found OR caller lacks Share permission"), ), @@ -172,28 +173,78 @@ pub async fn create_grant( caller_id ); - // Fire the invitation email AFTER the grant rows are in place so a - // failed SMTP send can't leave the recipient with mail-but-no-access. - // The service swallows SMTP errors (logs only) — the API response - // stays 201 Created either way, matching the plan's "201 always - // when grants land; mail is best-effort" contract. - if let Some(recipient) = invite_recipient - && let Some(invite_svc) = state.magic_link_invite_service.as_ref() - { - let inviter_name = auth_user.username.clone(); - if let Err(e) = invite_svc - .issue_invitation(&recipient, &inviter_name, resource) - .await - { - warn!( - "invitation issuance failed for {} (grants already created): {}", - recipient.email(), - e - ); - } - } + // PR N1 — route the post-grant notification through the unified + // RecipientNotificationService. Handles user/group/token subjects + // uniformly (Token subjects return an empty outcome set); applies + // per-(granter, recipient) coalesce + per-recipient hard rate + // limit; dispatches the magic-link arm (delegating to + // MagicLinkInviteService::issue_invitation) for eligible externals + // and the plain-notification arm for internal users; honours the + // per-user `notify_on_share` opt-out and the operator-level + // `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` flag. SMTP failures + // remain non-fatal — the grant rows are already in place and the + // service captures every per-recipient result as a NotifyOutcome + // rather than an Err. + // + // For the email-resolved subject variant we already loaded the + // recipient `User` above for the lazy-provision side effect; the + // notification service re-resolves the same id, which is cheap and + // keeps the entry-point signature uniform across subject types. + let _ = invite_recipient; // value used only for its side effect above - (StatusCode::CREATED, Json(results)).into_response() + // Load the granter as a full `User` entity — the notification + // service uses display fields (`username`, `given/family_name`) for + // the inviter label in the email body. Failure here means the JWT + // claims correspond to a user row that has since been deleted; we + // return the grants without a notification rather than rolling back. + let notification = match ( + state.recipient_notification_service.as_ref(), + state.auth_service.as_ref(), + ) { + (Some(svc), Some(auth_svc)) => { + match auth_svc + .auth_application_service + .get_user_entity(caller_id) + .await + { + Ok(granter) => match svc + .send_share_notification( + &granter, + subject, + resource, + NotifyTrigger::GrantCreated, + ) + .await + { + Ok(set) => set.to_dto(), + Err(e) => { + warn!( + "notification dispatch failed for grant action by {}: {}", + caller_id, e + ); + NotifyOutcomeSetDto::empty() + } + }, + Err(e) => { + warn!( + "granter {} user-row load failed; skipping notification: {}", + caller_id, e + ); + NotifyOutcomeSetDto::empty() + } + } + } + _ => NotifyOutcomeSetDto::empty(), + }; + + ( + StatusCode::CREATED, + Json(CreateGrantResponseDto { + grants: results, + notification, + }), + ) + .into_response() } // ════════════════════════════════════════════════════════════════════════════ @@ -246,6 +297,171 @@ pub async fn revoke_grant( StatusCode::NO_CONTENT.into_response() } +// ════════════════════════════════════════════════════════════════════════════ +// POST /api/grants/{id}/notify — manual share-notification resend +// ════════════════════════════════════════════════════════════════════════════ + +#[utoipa::path( + post, + path = "/api/grants/{id}/notify", + params(("id" = String, Path, description = "Grant UUID")), + responses( + (status = 204, description = "Notification(s) dispatched"), + (status = 200, description = "Mixed outcome (some recipients coalesced / not-applicable); body carries the full NotifyOutcomeSet", body = NotifyOutcomeSetDto), + (status = 404, description = "Grant not found OR caller is not the granter"), + (status = 409, description = "Token subject (use the existing /magic/v1/{token}/resend channel)"), + (status = 429, description = "Per-recipient hard rate limit exceeded"), + ), + security(("bearerAuth" = [])), + tag = "grants" +)] +pub async fn notify_grant_recipient( + State(state): State, + auth_user: AuthUser, + Path(id): Path, +) -> impl IntoResponse { + let authz = &state.authorization; + let caller_id = auth_user.id; + + let grant_id = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(), + }; + + // Load the grant. Anti-enumeration: missing AND not-owner both + // surface as 404 to the caller; only the audit row carries the + // real reason. Mirrors `revoke_grant`'s precedent. + let (subject, resource, granter_id) = match authz.find_grant_full_by_id(grant_id).await { + Ok(Some(t)) => t, + Ok(None) => { + tracing::info!( + target: "audit", + event = "grant.notify_skipped", + reason = "grant_not_found", + caller_id = %caller_id, + grant_id = %grant_id, + "🤫 manual notify rejected: grant {} not found", + grant_id, + ); + return AppError::not_found(format!("Grant {grant_id} not found")).into_response(); + } + Err(e) => return AppError::from(e).into_response(), + }; + + if granter_id != caller_id { + tracing::info!( + target: "audit", + event = "grant.notify_skipped", + reason = "not_owner", + caller_id = %caller_id, + grant_id = %grant_id, + actual_granter = %granter_id, + "🤫 manual notify rejected: caller {} is not the granter of {}", + caller_id, + grant_id, + ); + return AppError::not_found(format!("Grant {grant_id} not found")).into_response(); + } + + // Token subjects can't be notified — the link share has no human + // recipient to email. Map to 409 so the frontend can hide the menu + // item for these as defense-in-depth (the v1 UI already does this + // client-side; this is the server-side enforcement). + if matches!(subject, Subject::Token(_)) { + return AppError::new( + StatusCode::CONFLICT, + "Cannot notify a link-share recipient — token shares have no email channel", + "subject_is_token", + ) + .into_response(); + } + + // Load the granter entity (we are the granter; needed for the + // notification email body's "Alice shared X with you" salutation). + let Some(auth_svc) = state.auth_service.as_ref() else { + return AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "Authentication subsystem not available", + "ServiceUnavailable", + ) + .into_response(); + }; + let granter = match auth_svc + .auth_application_service + .get_user_entity(caller_id) + .await + { + Ok(u) => u, + Err(e) => return AppError::from(e).into_response(), + }; + + let Some(svc) = state.recipient_notification_service.as_ref() else { + return AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "Notification service is not configured on this server \ + (set OXICLOUD_SMTP_HOST in .env to enable)", + "ServiceUnavailable", + ) + .into_response(); + }; + + let outcome_set = match svc + .send_share_notification(&granter, subject, resource, NotifyTrigger::ManualResend) + .await + { + Ok(s) => s, + Err(e) => return AppError::from(e).into_response(), + }; + + let dto = outcome_set.to_dto(); + + // HTTP mapping per the plan: + // - empty outcomes (Token subject — already 409'd above; defense + // in depth) → 409 + // - every outcome is Sent → 204 No Content + // - all RateLimited (no Sent) → 429 with the longest Retry-After + // - mixed → 200 with the full body + if dto.outcomes.is_empty() { + return AppError::new( + StatusCode::CONFLICT, + "Grant has no notifiable recipients", + "subject_is_token", + ) + .into_response(); + } + + let any_sent = dto.outcomes.iter().any(|o| { + matches!( + o, + crate::application::dtos::grant_dto::NotifyOutcomeDto::Sent { .. } + ) + }); + let max_retry_after = dto + .outcomes + .iter() + .filter_map(|o| match o { + crate::application::dtos::grant_dto::NotifyOutcomeDto::RateLimited { + retry_after_secs, + } => Some(*retry_after_secs), + _ => None, + }) + .max(); + let all_sent = dto.outcomes.iter().all(|o| { + matches!( + o, + crate::application::dtos::grant_dto::NotifyOutcomeDto::Sent { .. } + ) + }); + + if all_sent { + return StatusCode::NO_CONTENT.into_response(); + } + if !any_sent && let Some(secs) = max_retry_after { + return crate::interfaces::middleware::rate_limit::too_many_requests(secs as u64); + } + (StatusCode::OK, Json(dto)).into_response() +} + // ════════════════════════════════════════════════════════════════════════════ // PUT /api/grants/role // ════════════════════════════════════════════════════════════════════════════ @@ -742,6 +958,7 @@ pub async fn list_my_shares( granted_at: g.granted_at, expires_at: g.expires_at, has_password: g.has_password, + is_external: g.is_external, }) .collect(); diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 8469e43f..4baff7c7 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -318,6 +318,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .route("/", post(grant_handler::create_grant)) .route("/", get(grant_handler::list_on_resource)) .route("/{id}", delete(grant_handler::revoke_grant)) + .route("/{id}/notify", post(grant_handler::notify_grant_recipient)) .route("/role", put(grant_handler::set_role)) .route("/incoming", get(grant_handler::list_incoming)) .route( diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js index 1341f08c..9b707434 100644 --- a/static/js/components/mySharesList.js +++ b/static/js/components/mySharesList.js @@ -9,6 +9,7 @@ * 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource */ +import { getCsrfHeaders } from '../core/csrf.js'; import { formatExpiryChip } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; @@ -432,6 +433,24 @@ class MySharesList { const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null; if (grant.subject_type === 'user' || grant.subject_type === 'group') { + // PR N2 — "Resend invitation email" / "Notify by email" / + // "Notify group members". First item in the menu; only + // present for user and group subjects (token shares have + // no email channel; the server returns 409 anyway). + const notifyLabel = + grant.subject_type === 'group' + ? i18n.t('myshares.notifyGroupMembers', 'Notify group members') + : grant.is_external + ? i18n.t('myshares.resendInvitation', 'Resend invitation email') + : i18n.t('myshares.notifyByEmail', 'Notify by email'); + menu.appendChild( + this._menuItem('fas fa-paper-plane', notifyLabel, false, async () => { + menu.remove(); + await this._notifyRecipient(grant); + }) + ); + menu.appendChild(this._menuSeparator()); + for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) { const isCurrent = grant.role === role; const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => { @@ -554,6 +573,68 @@ class MySharesList { return row; } + /** + * PR N2 — manual share-notification resend. Calls + * `POST /api/grants/{grant_id}/notify` and surfaces the aggregated + * outcome to the granter. The endpoint returns: + * - 204 No Content — all recipients sent + * - 200 + NotifyOutcomeSetDto — mixed outcomes (coalesced / + * not-applicable / partial sent) + * - 429 Too Many Requests — per-recipient rate limit hit on every + * recipient + * - 404 Not Found — caller is not the granter, or grant + * doesn't exist (anti-enumeration; the audit log carries the + * truth) + * - 409 Conflict — token subject (UI shouldn't reach this) + * + * @param {OutgoingResourceGrant} grant + */ + async _notifyRecipient(grant) { + try { + const resp = await fetch(`/api/grants/${encodeURIComponent(grant.grant_id)}/notify`, { + method: 'POST', + credentials: 'same-origin', + headers: { ...getCsrfHeaders() } + }); + if (resp.status === 204) { + // All sent — silent success. + console.log('[myshares] notify: all recipients sent', grant.grant_id); + return; + } + if (resp.status === 429) { + // eslint-disable-next-line no-alert -- minimal v1 surface + alert(i18n.t('myshares.notifyRateLimited', 'Too many notifications for this recipient — try again later.')); + return; + } + if (resp.ok) { + /** @type {{ total_recipients: number, outcomes: Array<{kind: string, detail?: string, reason?: string}> }} */ + const body = await resp.json(); + console.log('[myshares] notify outcomes:', body); + const sent = body.outcomes.filter((o) => o.kind === 'sent').length; + const coalesced = body.outcomes.filter((o) => o.kind === 'coalesced').length; + const notApplicable = body.outcomes.filter((o) => o.kind === 'not_applicable').length; + /** @type {string[]} */ + const lines = []; + if (sent > 0) lines.push(`${sent} recipient(s) notified by email.`); + if (coalesced > 0) lines.push(`${coalesced} recipient(s) already notified recently — they'll see the share at next login.`); + if (notApplicable > 0) lines.push(`${notApplicable} recipient(s) skipped (opted out, no email, or operator-disabled).`); + if (lines.length > 0) { + // eslint-disable-next-line no-alert -- minimal v1 surface + alert(lines.join('\n')); + } + return; + } + // 404 / 409 / unexpected + console.error('[myshares] notify failed:', resp.status); + // eslint-disable-next-line no-alert -- minimal v1 surface + alert(i18n.t('myshares.notifyFailed', 'Could not send notification.')); + } catch (err) { + console.error('[myshares] notify error:', err); + // eslint-disable-next-line no-alert -- minimal v1 surface + alert(i18n.t('myshares.notifyFailed', 'Could not send notification.')); + } + } + /** * Non-closing password row embedded in the link context menu. * Saves immediately on confirm (blur / Enter). diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index 4706ec92..2b3febdd 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -1004,6 +1004,13 @@ const shareModal = { const item = this._item; const itemType = this._itemType; + // Accumulate notification outcomes across all create-grant calls + // in this apply round so the post-apply summary aggregates ("3 + // recipients notified, 1 already notified recently") rather than + // showing one toast per granted member. + /** @type {Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}>} */ + const notifyOutcomes = []; + try { // ── Grants ───────────────────────────────────────────────────────── for (const m of this._localMembers) { @@ -1028,12 +1035,17 @@ const shareModal = { // user_id in the grant DTO. Until `fetchOutgoingGrants` // refreshes below, the row keeps the pending vignette. const subject = m._invitedEmail ? { type: 'email', email: m._invitedEmail } : { type: m.grant.subject.type, id: m.grant.subject.id }; - await grants.createGrant({ + const result = await grants.createGrant({ subject, resource: { type: itemType, id: item.id }, role: m.role, expires_at: expiresIso }); + // PR N1: collect per-recipient notification outcomes + // so we can show one aggregated summary after the loop. + if (result?.notification?.outcomes) { + notifyOutcomes.push(...result.notification.outcomes); + } } } @@ -1076,6 +1088,16 @@ const shareModal = { Modal.close(true); this._onApplied?.(); + + // PR N1: surface share-notification outcomes. The granter + // needs to know whether the recipient actually got an email + // (or was silently coalesced / rate-limited / opted out). + // Without a project-wide toast component the cheapest + // honest signal is a console log + a one-shot alert() for + // the non-success states. A proper toast surface lands in + // a small follow-up; the backend data is correct, the UI + // is just brief. + _surfaceNotifySummary(notifyOutcomes); } catch (err) { console.error('shareModal._applyAll error:', err); if (Modal.confirmBtn) Modal.confirmBtn.disabled = false; @@ -1083,4 +1105,54 @@ const shareModal = { } }; +/** + * Show a one-shot aggregated summary of share-notification outcomes + * after a batch of create-grant calls. v1 surface is minimal — logs + * everything to the console for traceability and pops a single alert() + * only when at least one recipient was coalesced, rate-limited, or + * landed on the not-applicable arm (i.e. the granter SHOULD know the + * email didn't go). The all-Sent happy path stays silent because the + * modal-close already implies success. + * + * A proper toast component is deferred; this function is the seam to + * upgrade later — replace the alert() body, keep the call site. + * + * @param {Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}>} outcomes + */ +function _surfaceNotifySummary(outcomes) { + if (!outcomes || outcomes.length === 0) return; + + // Always log — useful in dev tools regardless of the alert path. + console.log('[share] notification outcomes:', outcomes); + + const sent = outcomes.filter((o) => o.kind === 'sent').length; + const coalesced = outcomes.filter((o) => o.kind === 'coalesced').length; + const rateLimited = outcomes.filter((o) => o.kind === 'rate_limited').length; + const notApplicable = outcomes.filter((o) => o.kind === 'not_applicable'); + + // Happy path — all sent. Stay silent; the closed modal is the toast. + if (coalesced === 0 && rateLimited === 0 && notApplicable.length === 0) return; + + /** @type {string[]} */ + const lines = []; + if (sent > 0) { + lines.push(`${sent} recipient(s) notified by email.`); + } + if (coalesced > 0) { + lines.push(`${coalesced} recipient(s) already notified recently — they'll see the share at next login.`); + } + if (rateLimited > 0) { + lines.push(`${rateLimited} recipient(s) hit the notification rate limit — try again later.`); + } + if (notApplicable.length > 0) { + const reasons = notApplicable + .map((o) => o.reason) + .filter((r, i, arr) => r && arr.indexOf(r) === i) + .join(', '); + lines.push(`${notApplicable.length} recipient(s) skipped (${reasons || 'unknown'}).`); + } + // eslint-disable-next-line no-alert -- minimal v1 surface; toast component lands as follow-up + alert(lines.join('\n')); +} + export { shareModal }; diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 15b56daf..0bc61c40 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -338,6 +338,10 @@ const OxiIcons = { 576, 'm 360.55,24 v 72 h 64 c 79.5,0 144,64.5 144,144 0,93.4 -82.8,134.8 -100.6,142.6 -2.2,1 -4.6,1.4 -7.1,1.4 h -2.5 c -9.8,0 -17.8,-8 -17.8,-17.8 0,-8.3 5.9,-15.5 12.8,-20.3 8.9,-6.2 19.2,-18.2 19.2,-40.5 0,-45 -36.5,-81.5 -81.5,-81.5 h -30.5 v 72 c 0,9.7 -5.8,18.5 -14.8,22.2 -9,3.7 -19.3,1.7 -26.2,-5.2 l -136,-136 c -9.4,-9.4 -9.4,-24.6 0,-33.9 l 136,-136 c 6.9,-6.9 17.2,-8.9 26.2,-5.2 9,3.7 14.8,12.5 14.8,22.2 z M 112.5,96 c -44.2,0 -80,35.8 -80,80 v 256 c 0,44.2 35.8,80 80,80 h 256 c 44.2,0 80,-35.8 80,-80 v -32 c 0,-17.7 -14.3,-32 -32,-32 -17.7,0 -32,14.3 -32,32 v 32 c 0,8.8 -7.2,16 -16,16 h -256 c -8.8,0 -16,-7.2 -16,-16 V 176 c 0,-8.8 7.2,-16 16,-16 h 16 c 17.7,0 32,-14.3 32,-32 0,-17.7 -14.3,-32 -32,-32 z' ], + 'paper-plane': [ + 576, + 'M290.5 287.7L491.4 86.9 359 456.3 290.5 287.7zM457.4 53L256.6 253.8 88 185.3 457.4 53zM38.1 216.8l205.8 83.6 83.6 205.8c5.3 13.1 18.1 21.7 32.3 21.7 14.7 0 27.8-9.2 32.8-23.1L570.6 8c3.5-9.8 1-20.6-6.3-28s-18.2-9.8-28-6.3L39.4 151.7c-13.9 5-23.1 18.1-23.1 32.8 0 14.2 8.6 27 21.7 32.3z' + ], pause: [ 384, 'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z' diff --git a/static/js/core/types.js b/static/js/core/types.js index b74c5078..4c16fca2 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -166,6 +166,7 @@ * @property {string} [family_name] Last/family name; set at OIDC JIT or via PATCH /api/auth/me/profile (PR 24) * @property {string} [email_verified_at] ISO 8601 timestamp of the first proof-of-email-control (PR 23). Omitted when unverified. * @property {string} [preferred_locale] User-chosen locale code (e.g. `"fr"`, `"zh-TW"`); omitted when unset. Round-trips via PATCH /api/auth/me/profile. + * @property {boolean} notify_on_share Whether the user wants share-notification emails ("Alice shared X with you"). Default TRUE. Toggled via the profile checkbox; round-trips via PATCH /api/auth/me/profile. */ /** @@ -365,6 +366,7 @@ * @property {string} granted_at - ISO-8601 * @property {string|null} [expires_at] - ISO-8601 or absent. * @property {boolean} has_password - True when a token subject has a password set. + * @property {boolean} [is_external] - True when a user subject is a magic-link-only external user (PR N2). Drives the My Shares menu label ("Resend invitation email" vs "Notify by email"). Always false for token and group subjects. */ /** diff --git a/static/js/model/grants.js b/static/js/model/grants.js index f3b6b7de..a6890ff0 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -166,8 +166,26 @@ const grants = { * Create a new grant. * Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`. * + * Response shape (PR N1 — `CreateGrantResponseDto`): + * + * ```json + * { + * "grants": [ {Grant}, … ], + * "notification": { + * "total_recipients": 1, + * "outcomes": [{ "kind": "sent", "detail": "plain_notification" }] + * } + * } + * ``` + * + * `notification.outcomes` is empty for token subjects; size 1 for + * user subjects; size N for group subjects (one entry per resolved + * member). Callers that just need the grant rows can `.grants`; + * callers that want to surface "did Carol get my email?" UX read + * `.notification.outcomes[]`. + * * @param {Object} dto - CreateGrantDto shape - * @returns {Promise} + * @returns {Promise<{ grants: Grant[], notification: { total_recipients: number, outcomes: Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}> } }>} */ async createGrant(dto) { const response = await fetch('/api/grants', { diff --git a/static/js/views/profile/profile.js b/static/js/views/profile/profile.js index a14521bd..0f60a4b1 100644 --- a/static/js/views/profile/profile.js +++ b/static/js/views/profile/profile.js @@ -645,6 +645,14 @@ function _renderProfileEdit(user) { } givenInput.value = user.given_name || ''; familyInput.value = user.family_name || ''; + + const notifyInput = /** @type {HTMLInputElement | null} */ (document.getElementById('profile-edit-notify-on-share')); + if (notifyInput) { + // notify_on_share is a boolean on the server; default TRUE for + // pre-existing rows via the column default, so the checkbox is + // ticked unless the user has explicitly opted out. + notifyInput.checked = user.notify_on_share !== false; + } } /** @@ -665,7 +673,7 @@ async function submitProfile(e) { const givenInput = /** @type {HTMLInputElement} */ (document.getElementById('profile-edit-given-name')); const familyInput = /** @type {HTMLInputElement} */ (document.getElementById('profile-edit-family-name')); - /** @type {{ username?: string, given_name?: string, family_name?: string }} */ + /** @type {{ username?: string, given_name?: string, family_name?: string, notify_on_share?: boolean }} */ const body = {}; if (!usernameInput.disabled && usernameInput.value.trim()) { body.username = usernameInput.value.trim(); @@ -675,6 +683,15 @@ async function submitProfile(e) { const family = familyInput.value.trim(); if (family) body.family_name = family; + // Always send the share-notification preference. The backend + // compares against the current value and skips the write if + // unchanged, so this is idempotent — sending it on every save + // simplifies the frontend rather than tracking a dirty bit. + const notifyInput = /** @type {HTMLInputElement | null} */ (document.getElementById('profile-edit-notify-on-share')); + if (notifyInput) { + body.notify_on_share = notifyInput.checked; + } + if (Object.keys(body).length === 0) { statusEl.innerHTML = `
${escapeHtml(i18n.t('profile.profile_no_changes'))}
`; return false; diff --git a/static/locales/ar.json b/static/locales/ar.json index 31f2460d..dec9df29 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "شارك {{inviter}} معك {{kind}} على OxiCloud", - "body": "شارك {{inviter}} معك {{kind}} على OxiCloud.\n\nافتحه بالنقر على الرابط أدناه:\n{{link}}\n\nيعمل الرابط مرة واحدة وتنتهي صلاحيته خلال {{ttl_hours}} ساعة.\nإذا لم تكن تتوقع هذه الدعوة، يمكنك تجاهل هذه الرسالة.\n\n— OxiCloud" + "body": "شارك {{inviter_full}} معك {{kind}} على OxiCloud.\n\nافتحه بالنقر على الرابط أدناه:\n{{link}}\n\nيعمل الرابط مرة واحدة وتنتهي صلاحيته خلال {{ttl_hours}} ساعة.\nإذا لم تكن تتوقع هذه الدعوة، يمكنك تجاهل هذه الرسالة.\n\n— OxiCloud" }, "login": { "subject": "تسجيل الدخول إلى OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "مجلد", "english_fallback_divider": "--- النسخة الإنجليزية أدناه ---" } + }, + "notification": { + "share": { + "subject": "شارك {{inviter}} معك {{kind}} على OxiCloud", + "body": "شارك {{inviter_full}} معك {{kind}} على OxiCloud.\n\nافتح OxiCloud لعرض مشاركتك الجديدة:\n{{login_link}}\n\nقد تكون لديك مشاركات جديدة أخرى من {{inviter}} — سجّل الدخول لرؤية جميع العناصر المشاركة معك.\n\n— OxiCloud\n\nأنت تتلقى هذه الرسالة لأن لديك حسابًا في OxiCloud وتفضيل إشعارات المشاركة مُفعّل. يمكنك تعطيله من ملفك الشخصي (راسلني عندما يشاركني شخص ما)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "اسم المستخدم محدد ولا يمكن تغييره (عملاء DAV/NextCloud يعتمدون عليه).", "given_name": "الاسم الأول", "family_name": "اسم العائلة", + "notify_on_share": "أرسل لي بريدًا إلكترونيًا عندما يشاركني شخص ما", + "notify_on_share_hint": "عند إلغاء التحديد، ستظل المشاركات تظهر في حسابك — لن تتلقى فقط بريدًا إلكترونيًا بشأنها.", "save_profile": "حفظ التغييرات", "profile_saved": "تم تحديث الملف الشخصي", "profile_no_changes": "لا توجد تغييرات لحفظها.", diff --git a/static/locales/de.json b/static/locales/de.json index ac850ddb..f2ad3726 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt", - "body": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie ihn, indem Sie auf den folgenden Link klicken:\n{{link}}\n\nDer Link kann nur einmal verwendet werden und läuft in {{ttl_hours}} Stunden ab.\nFalls Sie diese Einladung nicht erwartet haben, können Sie diese Nachricht ignorieren.\n\n— OxiCloud" + "body": "{{inviter_full}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie ihn, indem Sie auf den folgenden Link klicken:\n{{link}}\n\nDer Link kann nur einmal verwendet werden und läuft in {{ttl_hours}} Stunden ab.\nFalls Sie diese Einladung nicht erwartet haben, können Sie diese Nachricht ignorieren.\n\n— OxiCloud" }, "login": { "subject": "Anmeldung bei OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "Ordner", "english_fallback_divider": "--- Englische Version unten ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt", + "body": "{{inviter_full}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie OxiCloud, um Ihre neue Freigabe zu sehen:\n{{login_link}}\n\nMöglicherweise gibt es weitere neue Freigaben von {{inviter}} — melden Sie sich an, um alle Ihre freigegebenen Elemente zu sehen.\n\n— OxiCloud\n\nSie erhalten diese Nachricht, weil Sie ein OxiCloud-Konto haben und die Benachrichtigung über Freigaben aktiviert ist. Sie können sie in Ihrem Profil deaktivieren (Per E-Mail benachrichtigen, wenn jemand mit mir teilt)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Benutzername ist gesetzt und kann nicht geändert werden (DAV/NextCloud-Clients hängen davon ab).", "given_name": "Vorname", "family_name": "Nachname", + "notify_on_share": "Mich per E-Mail benachrichtigen, wenn jemand mit mir teilt", + "notify_on_share_hint": "Wenn deaktiviert, werden Freigaben weiterhin in deinem Konto angezeigt — du erhältst nur keine E-Mail dazu.", "save_profile": "Änderungen speichern", "profile_saved": "Profil aktualisiert", "profile_no_changes": "Keine Änderungen zu speichern.", diff --git a/static/locales/en.json b/static/locales/en.json index baa27ecd..b2636368 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -30,12 +30,28 @@ "kind_folder": "folder", "english_fallback_divider": "--- English version below ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen OxiCloud to see your new share:\n{{login_link}}\n\nYou may have additional new shares from {{inviter}} — sign in to see all your shared items.\n\n— OxiCloud\n\nYou're receiving this message because you have an OxiCloud account and your share-notification preference is on. You can turn it off in your profile (Email me when someone shares with me)." + } } }, "app": { "title": "OxiCloud", "description": "Minimalist cloud storage system" }, + "myshares": { + "resendInvitation": "Resend invitation email", + "notifyByEmail": "Notify by email", + "notifyGroupMembers": "Notify group members", + "notifyRateLimited": "Too many notifications for this recipient — try again later.", + "notifyFailed": "Could not send notification.", + "removeAccess": "Remove access", + "copyLink": "Copy link", + "deleteLink": "Delete link" + }, "nav": { "files": "Files", "shared": "My shares", @@ -759,6 +775,8 @@ "username_already_claimed": "Username is set and can't be changed (DAV/NextCloud clients depend on it).", "given_name": "First name", "family_name": "Last name", + "notify_on_share": "Email me when someone shares with me", + "notify_on_share_hint": "When unchecked, shares still appear in your account — you just won't get an email about them.", "save_profile": "Save changes", "profile_saved": "Profile updated", "profile_no_changes": "No changes to save.", diff --git a/static/locales/es.json b/static/locales/es.json index 13d8aa89..7b459ab3 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud", - "body": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud.\n\nÁbrelo haciendo clic en el enlace de abajo:\n{{link}}\n\nEl enlace es de un solo uso y expira en {{ttl_hours}} horas.\nSi no esperabas esta invitación, puedes ignorar este mensaje.\n\n— OxiCloud" + "body": "{{inviter_full}} ha compartido un {{kind}} contigo en OxiCloud.\n\nÁbrelo haciendo clic en el enlace de abajo:\n{{link}}\n\nEl enlace es de un solo uso y expira en {{ttl_hours}} horas.\nSi no esperabas esta invitación, puedes ignorar este mensaje.\n\n— OxiCloud" }, "login": { "subject": "Inicia sesión en OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "carpeta", "english_fallback_divider": "--- Versión en inglés a continuación ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud", + "body": "{{inviter_full}} ha compartido un {{kind}} contigo en OxiCloud.\n\nAbre OxiCloud para ver tu nuevo recurso compartido:\n{{login_link}}\n\nPuede que tengas más recursos compartidos nuevos de {{inviter}} — inicia sesión para ver todos tus elementos compartidos.\n\n— OxiCloud\n\nRecibes este mensaje porque tienes una cuenta de OxiCloud y la preferencia de notificación de recursos compartidos está activada. Puedes desactivarla en tu perfil (Enviarme un correo cuando alguien comparta conmigo)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Nombre de usuario fijado y no modificable (los clientes DAV/NextCloud dependen de él).", "given_name": "Nombre", "family_name": "Apellidos", + "notify_on_share": "Enviarme un correo cuando alguien comparta conmigo", + "notify_on_share_hint": "Cuando esté desmarcado, los recursos compartidos seguirán apareciendo en tu cuenta — simplemente no recibirás un correo sobre ellos.", "save_profile": "Guardar cambios", "profile_saved": "Perfil actualizado", "profile_no_changes": "Sin cambios que guardar.", diff --git a/static/locales/fa.json b/static/locales/fa.json index 294249fc..1e7dc968 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت", - "body": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبا کلیک روی پیوند زیر آن را باز کنید:\n{{link}}\n\nپیوند یک‌بار مصرف است و در {{ttl_hours}} ساعت منقضی می‌شود.\nاگر منتظر این دعوت نبودید، می‌توانید این پیام را نادیده بگیرید.\n\n— OxiCloud" + "body": "{{inviter_full}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبا کلیک روی پیوند زیر آن را باز کنید:\n{{link}}\n\nپیوند یک‌بار مصرف است و در {{ttl_hours}} ساعت منقضی می‌شود.\nاگر منتظر این دعوت نبودید، می‌توانید این پیام را نادیده بگیرید.\n\n— OxiCloud" }, "login": { "subject": "ورود به OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "پوشه", "english_fallback_divider": "--- نسخهٔ انگلیسی در پایین ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت", + "body": "{{inviter_full}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبرای دیدن اشتراک‌گذاری جدید خود، OxiCloud را باز کنید:\n{{login_link}}\n\nممکن است اشتراک‌گذاری‌های جدید دیگری از {{inviter}} داشته باشید — وارد شوید تا همه موارد به اشتراک گذاشته‌شده با خود را ببینید.\n\n— OxiCloud\n\nشما این پیام را دریافت می‌کنید زیرا حساب OxiCloud دارید و گزینه اعلان اشتراک‌گذاری شما روشن است. می‌توانید آن را در پروفایل خود خاموش کنید (وقتی کسی با من چیزی به اشتراک می‌گذارد، به من ایمیل بزن)." + } } }, "app": { @@ -725,6 +731,8 @@ "username_already_claimed": "نام کاربری تنظیم شده و قابل تغییر نیست (کلاینت‌های DAV/NextCloud به آن وابسته‌اند).", "given_name": "نام", "family_name": "نام خانوادگی", + "notify_on_share": "وقتی کسی با من چیزی به اشتراک می‌گذارد، به من ایمیل بزن", + "notify_on_share_hint": "وقتی تیک‌خورده نباشد، اشتراک‌گذاری‌ها همچنان در حساب شما نمایش داده می‌شوند — فقط ایمیلی درباره آنها دریافت نخواهید کرد.", "save_profile": "ذخیره تغییرات", "profile_saved": "نمایه به‌روز شد", "profile_no_changes": "تغییری برای ذخیره وجود ندارد.", diff --git a/static/locales/fr.json b/static/locales/fr.json index 6b15e381..10b4d59e 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud", - "body": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez-le en cliquant sur le lien ci-dessous :\n{{link}}\n\nLe lien est à usage unique et expire dans {{ttl_hours}} heures.\nSi vous n'attendiez pas cette invitation, vous pouvez ignorer ce message.\n\n— OxiCloud" + "body": "{{inviter_full}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez-le en cliquant sur le lien ci-dessous :\n{{link}}\n\nLe lien est à usage unique et expire dans {{ttl_hours}} heures.\nSi vous n'attendiez pas cette invitation, vous pouvez ignorer ce message.\n\n— OxiCloud" }, "login": { "subject": "Connexion à OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "dossier", "english_fallback_divider": "--- Version anglaise ci-dessous ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud", + "body": "{{inviter_full}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez OxiCloud pour voir votre nouveau partage :\n{{login_link}}\n\nVous avez peut-être d'autres nouveaux partages de {{inviter}} — connectez-vous pour voir tous vos éléments partagés.\n\n— OxiCloud\n\nVous recevez ce message parce que vous avez un compte OxiCloud et que la préférence de notification de partage est activée. Vous pouvez la désactiver dans votre profil (M'avertir par e-mail quand quelqu'un partage avec moi)." + } } }, "app": { @@ -759,6 +765,8 @@ "username_already_claimed": "Nom d'utilisateur fixé et non modifiable (les clients DAV/NextCloud en dépendent).", "given_name": "Prénom", "family_name": "Nom", + "notify_on_share": "M'avertir par e-mail quand quelqu'un partage avec moi", + "notify_on_share_hint": "Lorsque décoché, les partages apparaissent toujours dans votre compte — vous ne recevrez simplement pas d'e-mail à leur sujet.", "save_profile": "Enregistrer", "profile_saved": "Profil mis à jour", "profile_no_changes": "Aucun changement à enregistrer.", diff --git a/static/locales/hi.json b/static/locales/hi.json index b5c007bc..32d7aee4 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया", - "body": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nइसे नीचे दिए गए लिंक पर क्लिक करके खोलें:\n{{link}}\n\nलिंक केवल एक बार काम करता है और {{ttl_hours}} घंटों में समाप्त हो जाता है।\nयदि आप इस आमंत्रण की अपेक्षा नहीं कर रहे थे, तो आप इस संदेश को अनदेखा कर सकते हैं।\n\n— OxiCloud" + "body": "{{inviter_full}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nइसे नीचे दिए गए लिंक पर क्लिक करके खोलें:\n{{link}}\n\nलिंक केवल एक बार काम करता है और {{ttl_hours}} घंटों में समाप्त हो जाता है।\nयदि आप इस आमंत्रण की अपेक्षा नहीं कर रहे थे, तो आप इस संदेश को अनदेखा कर सकते हैं।\n\n— OxiCloud" }, "login": { "subject": "OxiCloud में साइन इन करें", @@ -30,6 +30,12 @@ "kind_folder": "फ़ोल्डर", "english_fallback_divider": "--- अंग्रेज़ी संस्करण नीचे ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया", + "body": "{{inviter_full}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nअपना नया साझाकरण देखने के लिए OxiCloud खोलें:\n{{login_link}}\n\nहो सकता है आपके पास {{inviter}} से और भी नए साझाकरण हों — साइन इन करें और अपने सभी साझा किए गए आइटम देखें।\n\n— OxiCloud\n\nआपको यह संदेश इसलिए मिल रहा है क्योंकि आपका OxiCloud खाता है और साझाकरण-सूचना प्राथमिकता चालू है। आप इसे अपनी प्रोफ़ाइल में बंद कर सकते हैं (जब कोई मेरे साथ साझा करे तो मुझे ईमेल भेजें)।" + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "उपयोगकर्ता नाम सेट है और बदला नहीं जा सकता (DAV/NextCloud क्लाइंट इस पर निर्भर करते हैं)।", "given_name": "प्रथम नाम", "family_name": "अंतिम नाम", + "notify_on_share": "जब कोई मेरे साथ साझा करे तो मुझे ईमेल भेजें", + "notify_on_share_hint": "जब अनचेक किया जाए, तो साझाकरण आपके खाते में दिखाई देते रहेंगे — आपको बस उनके बारे में ईमेल नहीं मिलेगा।", "save_profile": "परिवर्तन सहेजें", "profile_saved": "प्रोफ़ाइल अद्यतन की गई", "profile_no_changes": "सहेजने के लिए कोई परिवर्तन नहीं।", diff --git a/static/locales/it.json b/static/locales/it.json index e9d0ee7f..0348bb7f 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud", - "body": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud.\n\nAprilo facendo clic sul link sottostante:\n{{link}}\n\nIl link è monouso e scade tra {{ttl_hours}} ore.\nSe non ti aspettavi questo invito, puoi ignorare questo messaggio.\n\n— OxiCloud" + "body": "{{inviter_full}} ha condiviso un {{kind}} con te su OxiCloud.\n\nAprilo facendo clic sul link sottostante:\n{{link}}\n\nIl link è monouso e scade tra {{ttl_hours}} ore.\nSe non ti aspettavi questo invito, puoi ignorare questo messaggio.\n\n— OxiCloud" }, "login": { "subject": "Accedi a OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "cartella", "english_fallback_divider": "--- Versione inglese qui sotto ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud", + "body": "{{inviter_full}} ha condiviso un {{kind}} con te su OxiCloud.\n\nApri OxiCloud per vedere la tua nuova condivisione:\n{{login_link}}\n\nPotresti avere altre nuove condivisioni da {{inviter}} — accedi per vedere tutti gli elementi condivisi con te.\n\n— OxiCloud\n\nRicevi questo messaggio perché hai un account OxiCloud e la preferenza di notifica delle condivisioni è attiva. Puoi disattivarla dal tuo profilo (Avvisami via email quando qualcuno condivide con me)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Nome utente impostato e non modificabile (i client DAV/NextCloud dipendono da esso).", "given_name": "Nome", "family_name": "Cognome", + "notify_on_share": "Avvisami via email quando qualcuno condivide con me", + "notify_on_share_hint": "Se deselezionato, le condivisioni continueranno ad apparire nel tuo account — semplicemente non riceverai un'email a riguardo.", "save_profile": "Salva modifiche", "profile_saved": "Profilo aggiornato", "profile_no_changes": "Nessuna modifica da salvare.", diff --git a/static/locales/ja.json b/static/locales/ja.json index fb6fa3c0..7e3951ff 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} さんが OxiCloud で {{kind}} をあなたと共有しました", - "body": "{{inviter}} さんが OxiCloud で {{kind}} をあなたと共有しました。\n\n以下のリンクをクリックして開いてください:\n{{link}}\n\nリンクは一度のみ有効で、{{ttl_hours}} 時間で期限切れになります。\nこの招待に心当たりがない場合は、このメッセージを無視していただいて結構です。\n\n— OxiCloud" + "body": "{{inviter_full}} さんが OxiCloud で {{kind}} をあなたと共有しました。\n\n以下のリンクをクリックして開いてください:\n{{link}}\n\nリンクは一度のみ有効で、{{ttl_hours}} 時間で期限切れになります。\nこの招待に心当たりがない場合は、このメッセージを無視していただいて結構です。\n\n— OxiCloud" }, "login": { "subject": "OxiCloud にサインイン", @@ -30,6 +30,12 @@ "kind_folder": "フォルダー", "english_fallback_divider": "--- 以下は英語版 ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} さんが OxiCloud で {{kind}} をあなたと共有しました", + "body": "{{inviter_full}} さんが OxiCloud で {{kind}} をあなたと共有しました。\n\n新しい共有を確認するには OxiCloud を開いてください:\n{{login_link}}\n\n{{inviter}} さんから他にも新しい共有があるかもしれません — サインインしてあなたと共有されたすべての項目を確認してください。\n\n— OxiCloud\n\nOxiCloud のアカウントをお持ちで、共有通知の設定が有効になっているため、このメッセージが届いています。プロフィールでオフにできます(誰かが共有したときにメールで通知する)。" + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "ユーザー名は設定済みで変更できません(DAV/NextCloudクライアントが依存します)。", "given_name": "名", "family_name": "姓", + "notify_on_share": "誰かが共有したときにメールで通知する", + "notify_on_share_hint": "チェックを外しても、共有はアカウントに表示されますが、メールでの通知は届きません。", "save_profile": "変更を保存", "profile_saved": "プロフィールを更新しました", "profile_no_changes": "保存する変更はありません。", diff --git a/static/locales/ko.json b/static/locales/ko.json index a278f999..fd71c784 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다", - "body": "{{inviter}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다.\n\n아래 링크를 클릭하여 여세요:\n{{link}}\n\n링크는 한 번만 사용 가능하며 {{ttl_hours}}시간 후에 만료됩니다.\n이 초대를 예상하지 못했다면 이 메시지를 무시하셔도 됩니다.\n\n— OxiCloud" + "body": "{{inviter_full}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다.\n\n아래 링크를 클릭하여 여세요:\n{{link}}\n\n링크는 한 번만 사용 가능하며 {{ttl_hours}}시간 후에 만료됩니다.\n이 초대를 예상하지 못했다면 이 메시지를 무시하셔도 됩니다.\n\n— OxiCloud" }, "login": { "subject": "OxiCloud 로그인", @@ -30,6 +30,12 @@ "kind_folder": "폴더", "english_fallback_divider": "--- 영어 버전은 아래 ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다", + "body": "{{inviter_full}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다.\n\n새 공유 항목을 확인하려면 OxiCloud를 여세요:\n{{login_link}}\n\n{{inviter}}님이 추가로 공유한 항목이 있을 수 있습니다 — 로그인하여 공유받은 모든 항목을 확인하세요.\n\n— OxiCloud\n\nOxiCloud 계정이 있고 공유 알림 기본 설정이 켜져 있어 이 메시지를 받았습니다. 프로필에서 끌 수 있습니다(다른 사람이 나에게 공유할 때 이메일로 알림 받기)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "사용자 이름이 설정되어 있어 변경할 수 없습니다(DAV/NextCloud 클라이언트가 이에 의존합니다).", "given_name": "이름", "family_name": "성", + "notify_on_share": "다른 사람이 나에게 공유할 때 이메일로 알림 받기", + "notify_on_share_hint": "선택을 해제해도 공유 항목은 계정에 계속 표시되지만, 이메일 알림은 받지 않습니다.", "save_profile": "변경 사항 저장", "profile_saved": "프로필이 업데이트되었습니다", "profile_no_changes": "저장할 변경 사항이 없습니다.", diff --git a/static/locales/nl.json b/static/locales/nl.json index 87f46fd6..f7de5637 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} heeft een {{kind}} met je gedeeld op OxiCloud", - "body": "{{inviter}} heeft een {{kind}} met je gedeeld op OxiCloud.\n\nOpen het door op de onderstaande link te klikken:\n{{link}}\n\nDe link werkt eenmalig en verloopt over {{ttl_hours}} uur.\nAls je deze uitnodiging niet verwacht, kun je dit bericht negeren.\n\n— OxiCloud" + "body": "{{inviter_full}} heeft een {{kind}} met je gedeeld op OxiCloud.\n\nOpen het door op de onderstaande link te klikken:\n{{link}}\n\nDe link werkt eenmalig en verloopt over {{ttl_hours}} uur.\nAls je deze uitnodiging niet verwacht, kun je dit bericht negeren.\n\n— OxiCloud" }, "login": { "subject": "Aanmelden bij OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "map", "english_fallback_divider": "--- Engelse versie hieronder ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} heeft een {{kind}} met je gedeeld op OxiCloud", + "body": "{{inviter_full}} heeft een {{kind}} met je gedeeld op OxiCloud.\n\nOpen OxiCloud om je nieuwe gedeelde item te bekijken:\n{{login_link}}\n\nMisschien heb je nog meer nieuwe gedeelde items van {{inviter}} — meld je aan om al je gedeelde items te zien.\n\n— OxiCloud\n\nJe ontvangt dit bericht omdat je een OxiCloud-account hebt en je voorkeur voor deelmeldingen aanstaat. Je kunt het uitzetten in je profiel (Stuur me een e-mail wanneer iemand iets met mij deelt)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Gebruikersnaam ingesteld en niet wijzigbaar (DAV/NextCloud-clients zijn ervan afhankelijk).", "given_name": "Voornaam", "family_name": "Achternaam", + "notify_on_share": "Stuur me een e-mail wanneer iemand iets met mij deelt", + "notify_on_share_hint": "Wanneer uitgevinkt, verschijnen gedeelde items nog steeds in je account — je krijgt er alleen geen e-mail over.", "save_profile": "Wijzigingen opslaan", "profile_saved": "Profiel bijgewerkt", "profile_no_changes": "Geen wijzigingen om op te slaan.", diff --git a/static/locales/pl.json b/static/locales/pl.json index 57478719..f02e6783 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} udostępnił Ci {{kind}} w OxiCloud", - "body": "{{inviter}} udostępnił Ci {{kind}} w OxiCloud.\n\nOtwórz, klikając poniższy link:\n{{link}}\n\nLink działa raz i wygasa za {{ttl_hours}} godzin.\nJeśli nie spodziewałeś się tego zaproszenia, możesz zignorować tę wiadomość.\n\n— OxiCloud" + "body": "{{inviter_full}} udostępnił Ci {{kind}} w OxiCloud.\n\nOtwórz, klikając poniższy link:\n{{link}}\n\nLink działa raz i wygasa za {{ttl_hours}} godzin.\nJeśli nie spodziewałeś się tego zaproszenia, możesz zignorować tę wiadomość.\n\n— OxiCloud" }, "login": { "subject": "Zaloguj się do OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "folder", "english_fallback_divider": "--- Wersja angielska poniżej ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} udostępnił Ci {{kind}} w OxiCloud", + "body": "{{inviter_full}} udostępnił Ci {{kind}} w OxiCloud.\n\nOtwórz OxiCloud, aby zobaczyć nowe udostępnienie:\n{{login_link}}\n\nMożesz mieć dodatkowe nowe udostępnienia od {{inviter}} — zaloguj się, aby zobaczyć wszystkie udostępnione Ci elementy.\n\n— OxiCloud\n\nOtrzymujesz tę wiadomość, ponieważ masz konto OxiCloud i preferencja powiadomień o udostępnieniach jest włączona. Możesz ją wyłączyć w swoim profilu (Wyślij mi e-mail, gdy ktoś coś mi udostępni)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Nazwa użytkownika jest ustawiona i nie może być zmieniona (klienty DAV/NextCloud są od niej zależne).", "given_name": "Imię", "family_name": "Nazwisko", + "notify_on_share": "Wyślij mi e-mail, gdy ktoś coś mi udostępni", + "notify_on_share_hint": "Gdy odznaczone, udostępnienia nadal pojawiają się na Twoim koncie — po prostu nie otrzymasz o nich e-maila.", "save_profile": "Zapisz zmiany", "profile_saved": "Profil zaktualizowany", "profile_no_changes": "Brak zmian do zapisania.", diff --git a/static/locales/pt.json b/static/locales/pt.json index 56cb37b0..9fd032db 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} partilhou um {{kind}} consigo no OxiCloud", - "body": "{{inviter}} partilhou um {{kind}} consigo no OxiCloud.\n\nAbra-o clicando no link abaixo:\n{{link}}\n\nO link é de uso único e expira em {{ttl_hours}} horas.\nSe não esperava este convite, pode ignorar esta mensagem.\n\n— OxiCloud" + "body": "{{inviter_full}} partilhou um {{kind}} consigo no OxiCloud.\n\nAbra-o clicando no link abaixo:\n{{link}}\n\nO link é de uso único e expira em {{ttl_hours}} horas.\nSe não esperava este convite, pode ignorar esta mensagem.\n\n— OxiCloud" }, "login": { "subject": "Iniciar sessão no OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "pasta", "english_fallback_divider": "--- Versão em inglês abaixo ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} partilhou um {{kind}} consigo no OxiCloud", + "body": "{{inviter_full}} partilhou um {{kind}} consigo no OxiCloud.\n\nAbra o OxiCloud para ver a sua nova partilha:\n{{login_link}}\n\nPode ter mais partilhas novas de {{inviter}} — inicie sessão para ver todos os itens partilhados consigo.\n\n— OxiCloud\n\nRecebeu esta mensagem porque tem uma conta OxiCloud e a preferência de notificação de partilhas está ativada. Pode desativá-la no seu perfil (Avisar-me por e-mail quando alguém compartilhar comigo)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Nome de usuário definido e não pode ser alterado (clientes DAV/NextCloud dependem dele).", "given_name": "Nome", "family_name": "Sobrenome", + "notify_on_share": "Avisar-me por e-mail quando alguém compartilhar comigo", + "notify_on_share_hint": "Quando desmarcado, os compartilhamentos continuarão aparecendo na sua conta — você apenas não receberá um e-mail sobre eles.", "save_profile": "Salvar alterações", "profile_saved": "Perfil atualizado", "profile_no_changes": "Sem alterações para salvar.", diff --git a/static/locales/ru.json b/static/locales/ru.json index 99c96b82..a5fe8d17 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} поделился(ась) с вами {{kind}} в OxiCloud", - "body": "{{inviter}} поделился(ась) с вами {{kind}} в OxiCloud.\n\nОткройте, нажав на ссылку ниже:\n{{link}}\n\nСсылка работает один раз и истекает через {{ttl_hours}} часов.\nЕсли вы не ожидали этого приглашения, можете спокойно проигнорировать это сообщение.\n\n— OxiCloud" + "body": "{{inviter_full}} поделился(ась) с вами {{kind}} в OxiCloud.\n\nОткройте, нажав на ссылку ниже:\n{{link}}\n\nСсылка работает один раз и истекает через {{ttl_hours}} часов.\nЕсли вы не ожидали этого приглашения, можете спокойно проигнорировать это сообщение.\n\n— OxiCloud" }, "login": { "subject": "Вход в OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "папку", "english_fallback_divider": "--- Английская версия ниже ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} поделился(ась) с вами {{kind}} в OxiCloud", + "body": "{{inviter_full}} поделился(ась) с вами {{kind}} в OxiCloud.\n\nОткройте OxiCloud, чтобы увидеть новый общий ресурс:\n{{login_link}}\n\nВозможно, у вас есть и другие новые общие ресурсы от {{inviter}} — войдите, чтобы увидеть все элементы, которыми с вами поделились.\n\n— OxiCloud\n\nВы получаете это сообщение, потому что у вас есть учётная запись OxiCloud и предпочтение уведомлений об общих ресурсах включено. Вы можете отключить его в своём профиле (Уведомлять меня по электронной почте, когда кто-то делится со мной)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Имя пользователя установлено и не может быть изменено (клиенты DAV/NextCloud зависят от него).", "given_name": "Имя", "family_name": "Фамилия", + "notify_on_share": "Уведомлять меня по электронной почте, когда кто-то делится со мной", + "notify_on_share_hint": "Если флажок снят, общие ресурсы по-прежнему будут отображаться в вашей учётной записи — вы просто не будете получать о них письма.", "save_profile": "Сохранить изменения", "profile_saved": "Профиль обновлён", "profile_no_changes": "Нет изменений для сохранения.", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index ef4a696d..85dbc0bc 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} 在 OxiCloud 上與您分享了一個{{kind}}", - "body": "{{inviter}} 在 OxiCloud 上與您分享了一個{{kind}}。\n\n點擊下方連結開啟:\n{{link}}\n\n該連結僅可使用一次,並將在 {{ttl_hours}} 小時後過期。\n如果您未預期收到此邀請,可以忽略此訊息。\n\n— OxiCloud" + "body": "{{inviter_full}} 在 OxiCloud 上與您分享了一個{{kind}}。\n\n點擊下方連結開啟:\n{{link}}\n\n該連結僅可使用一次,並將在 {{ttl_hours}} 小時後過期。\n如果您未預期收到此邀請,可以忽略此訊息。\n\n— OxiCloud" }, "login": { "subject": "登入 OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "資料夾", "english_fallback_divider": "--- 以下為英文版本 ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} 在 OxiCloud 上與您分享了一個{{kind}}", + "body": "{{inviter_full}} 在 OxiCloud 上與您分享了一個{{kind}}。\n\n開啟 OxiCloud 檢視您的新分享:\n{{login_link}}\n\n您可能還有來自 {{inviter}} 的其他新分享 — 登入以檢視所有與您分享的項目。\n\n— OxiCloud\n\n您收到此訊息是因為您擁有 OxiCloud 帳戶且分享通知偏好已開啟。您可以在個人資料中關閉它(當有人與我分享時透過電子郵件通知我)。" + } } }, "app": { @@ -725,6 +731,8 @@ "username_already_claimed": "使用者名稱已設定,不可更改(DAV/NextCloud 用戶端依賴它)。", "given_name": "名", "family_name": "姓", + "notify_on_share": "當有人與我分享時透過電子郵件通知我", + "notify_on_share_hint": "取消勾選後,分享項目仍會顯示在您的帳戶中 — 只是不會收到相關郵件通知。", "save_profile": "儲存變更", "profile_saved": "個人資料已更新", "profile_no_changes": "沒有變更可儲存。", diff --git a/static/locales/zh.json b/static/locales/zh.json index 432e105e..fb1de088 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} 在 OxiCloud 上与您共享了一个{{kind}}", - "body": "{{inviter}} 在 OxiCloud 上与您共享了一个{{kind}}。\n\n点击下方链接打开:\n{{link}}\n\n该链接仅可使用一次,并将在 {{ttl_hours}} 小时后过期。\n如果您未预期收到此邀请,可以忽略此消息。\n\n— OxiCloud" + "body": "{{inviter_full}} 在 OxiCloud 上与您共享了一个{{kind}}。\n\n点击下方链接打开:\n{{link}}\n\n该链接仅可使用一次,并将在 {{ttl_hours}} 小时后过期。\n如果您未预期收到此邀请,可以忽略此消息。\n\n— OxiCloud" }, "login": { "subject": "登录 OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "文件夹", "english_fallback_divider": "--- 以下为英文版本 ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} 在 OxiCloud 上与您共享了一个{{kind}}", + "body": "{{inviter_full}} 在 OxiCloud 上与您共享了一个{{kind}}。\n\n打开 OxiCloud 查看您的新共享:\n{{login_link}}\n\n您可能还有来自 {{inviter}} 的其他新共享 — 登录以查看所有共享给您的项目。\n\n— OxiCloud\n\n您收到此消息是因为您拥有 OxiCloud 账户且共享通知偏好已开启。您可以在个人资料中关闭它(当有人与我共享时通过电子邮件通知我)。" + } } }, "app": { @@ -725,6 +731,8 @@ "username_already_claimed": "用户名已设置,不可更改(DAV/NextCloud 客户端依赖它)。", "given_name": "名", "family_name": "姓", + "notify_on_share": "当有人与我共享时通过电子邮件通知我", + "notify_on_share_hint": "取消勾选后,共享项目仍会显示在您的账户中 — 只是不会收到相关邮件通知。", "save_profile": "保存更改", "profile_saved": "个人资料已更新", "profile_no_changes": "无更改可保存。", diff --git a/static/profile.html b/static/profile.html index 0c3ccdf6..e1d8073b 100644 --- a/static/profile.html +++ b/static/profile.html @@ -139,6 +139,13 @@ +
+ + When unchecked, shares still appear in your account — you just won't get an email about them. +
diff --git a/tests/api/external_users.hurl b/tests/api/external_users.hurl index 3a1ea8e8..b5e2ac1c 100644 --- a/tests/api/external_users.hurl +++ b/tests/api/external_users.hurl @@ -63,11 +63,13 @@ Content-Type: application/json HTTP 201 # The response carries the resolved subject as a regular user UUID — # externals never surface as a distinct subject_type post-PR-9.3a. +# PR N1: POST /api/grants now wraps the array in +# `CreateGrantResponseDto { grants, notification }`. [Asserts] -jsonpath "$[0].subject.type" == "user" -jsonpath "$[0].resource.id" == "{{ext_folder_id}}" +jsonpath "$.grants[0].subject.type" == "user" +jsonpath "$.grants[0].resource.id" == "{{ext_folder_id}}" [Captures] -bob_user_id: jsonpath "$[0].subject.id" +bob_user_id: jsonpath "$.grants[0].subject.id" # ───────────────────────────────────────────────────────────── @@ -144,7 +146,7 @@ Content-Type: application/json HTTP 201 [Asserts] -jsonpath "$[0].subject.id" == "{{bob_user_id}}" +jsonpath "$.grants[0].subject.id" == "{{bob_user_id}}" # ───────────────────────────────────────────────────────────── @@ -447,7 +449,7 @@ Content-Type: application/json HTTP 201 [Captures] -rl_user_1_id: jsonpath "$[0].subject.id" +rl_user_1_id: jsonpath "$.grants[0].subject.id" # 16b — 4th invite (4/3) is rejected with 429 + Retry-After. The # cap is visible because Alice is authenticated and her own diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 2d2220e4..3c5214ac 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -122,9 +122,11 @@ Content-Type: application/json } HTTP 201 +# PR N1: POST /api/grants now wraps results in +# `CreateGrantResponseDto { grants, notification }`. [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].permission" == "read" +jsonpath "$.grants" count == 1 +jsonpath "$.grants[0].permission" == "read" # ───────────────────────────────────────────────────────────── @@ -205,7 +207,7 @@ Content-Type: application/json HTTP 201 [Captures] -eve_grant_id: jsonpath "$[0].id" +eve_grant_id: jsonpath "$.grants[0].id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/grants_nested_groups.hurl b/tests/api/grants_nested_groups.hurl index d8b6dee7..0acf11a1 100644 --- a/tests/api/grants_nested_groups.hurl +++ b/tests/api/grants_nested_groups.hurl @@ -281,11 +281,13 @@ Content-Type: application/json } HTTP 201 +# PR N1: POST /api/grants now wraps results in +# `CreateGrantResponseDto { grants, notification }`. [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].permission" == "read" -jsonpath "$[0].subject.type" == "group" -jsonpath "$[0].subject.id" == "{{group_a_id}}" +jsonpath "$.grants" count == 1 +jsonpath "$.grants[0].permission" == "read" +jsonpath "$.grants[0].subject.type" == "group" +jsonpath "$.grants[0].subject.id" == "{{group_a_id}}" # ── Read endpoints now succeed ────────────────────────────── GET {{base_url}}/api/folders/{{perm_folder_id}}/resources?resource_types=folder From 3eb74f83b05eca7317569f8cca838292575a8c87 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 10:05:51 +0200 Subject: [PATCH 2/8] feat(userVignette): show by preference givenname/familyname --- static/js/components/userVignette.js | 13 +++++++++- static/js/model/systemUsers.js | 37 +++++++++++++++++++++++----- static/locales/en.json | 4 +-- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/static/js/components/userVignette.js b/static/js/components/userVignette.js index 1bee1e16..a760cc9a 100644 --- a/static/js/components/userVignette.js +++ b/static/js/components/userVignette.js @@ -155,14 +155,25 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE // longer existed in the DOM, leaving the badge invisible until // the next render. Creating-then-appending keeps the icon system // and our reveal step in agreement. + // + // We always fetch the email — when `showEmail` is false (the common + // case) it's still used as the hover-tooltip on the vignette so the + // recipient identifier stays discoverable without visual clutter. Promise.all([ systemUsers.getDisplayName(userId), systemUsers.getPhoto(userId), - emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null), + systemUsers.getEmail(userId), showOrigin ? systemUsers.getIsExternal(userId) : Promise.resolve(false) ]).then(([name, photo, email, isExternal]) => { if (nameEl) nameEl.textContent = name; if (emailEl) emailEl.textContent = email ?? ''; + // Tooltip: surface the email on hover when it's not already + // rendered as the visible label (showEmail mode) and isn't + // already the displayed name (the fallback case where the user + // has no given/family/username and the label IS the email). + if (email && !showEmail && email !== name) { + wrapper.title = email; + } if (photo) { _applyPhoto(avatar, photo, name); } else { diff --git a/static/js/model/systemUsers.js b/static/js/model/systemUsers.js index 8b7c9767..e2f1db39 100644 --- a/static/js/model/systemUsers.js +++ b/static/js/model/systemUsers.js @@ -53,6 +53,31 @@ function _nameFor(c) { return `${c.id.slice(0, 8)}…`; } +/** + * Derive the best display name from a `User` shape (i.e. the + * `/api/users/{id}` payload OR the `oxicloud_user` localStorage blob). + * Priority — matches the server-side `User::display_full()` rule sans + * the email decoration; the `` part is added in the vignette + * layer as a tooltip when the email isn't already in the displayed + * label: + * + * 1. `"Given Family"` — both names set + * 2. `username` — handle (the typical case for password / OIDC + * users with no profile claims) + * 3. `email` — last resort but unambiguous + * 4. shortened UUID — failure mode (e.g. /api/users/{id} returned + * nothing usable) + * + * @param {{id?: string, given_name?: string|null, family_name?: string|null, username?: string|null, email?: string|null}} u + * @returns {string} + */ +function _displayNameFromUser(u) { + if (u.given_name && u.family_name) return `${u.given_name} ${u.family_name}`; + if (u.username) return u.username; + if (u.email) return u.email; + return u.id ? `${u.id.slice(0, 8)}…` : '?'; +} + /** * Ensure both indexes are built (idempotent). * After loading contacts from the system address book, the current user @@ -93,13 +118,13 @@ async function _ensureIndex() { try { const raw = localStorage.getItem('oxicloud_user'); if (raw) { - const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string, image?:string|null, is_external?:boolean}} */ ( - JSON.parse(raw) - ); + const u = + /** @type {{id?:string, given_name?:string|null, family_name?:string|null, username?:string|null, email?:string|null, image?:string|null, is_external?:boolean}} */ ( + JSON.parse(raw) + ); if (u?.id) { if (!_index.has(u.id)) { - const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`; - _index.set(u.id, name); + _index.set(u.id, _displayNameFromUser(u)); } if (!_photoIndex.has(u.id)) { _photoIndex.set(u.id, u.image ?? null); @@ -143,7 +168,7 @@ async function _resolveMissing(userId) { if (!resp.ok) return; /** @type {User} */ const u = await resp.json(); - _index?.set(u.id, u.username || u.email || `${u.id.slice(0, 8)}…`); + _index?.set(u.id, _displayNameFromUser(u)); _photoIndex?.set(u.id, u.image ?? null); _emailIndex?.set(u.id, u.email ?? null); _externalIndex?.set(u.id, !!u.is_external); diff --git a/static/locales/en.json b/static/locales/en.json index b2636368..4ed01aef 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", - "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + "body": "{{inviter_full}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" }, "login": { "subject": "Sign in to OxiCloud", @@ -34,7 +34,7 @@ "notification": { "share": { "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", - "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen OxiCloud to see your new share:\n{{login_link}}\n\nYou may have additional new shares from {{inviter}} — sign in to see all your shared items.\n\n— OxiCloud\n\nYou're receiving this message because you have an OxiCloud account and your share-notification preference is on. You can turn it off in your profile (Email me when someone shares with me)." + "body": "{{inviter_full}} shared a {{kind}} with you on OxiCloud.\n\nOpen OxiCloud to see your new share:\n{{login_link}}\n\nYou may have additional new shares from {{inviter}} — sign in to see all your shared items.\n\n— OxiCloud\n\nYou're receiving this message because you have an OxiCloud account and your share-notification preference is on. You can turn it off in your profile (Email me when someone shares with me)." } } }, From ba9922bcb8793ba3ba0af6959ee0451d9d7debcf Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 10:31:31 +0200 Subject: [PATCH 3/8] i18n: add missing translatiions --- static/js/components/mySharesList.js | 2 +- static/js/core/icons.js | 4 ++++ static/locales/ar.json | 31 ++++++++++++++++++++++++++-- static/locales/de.json | 31 ++++++++++++++++++++++++++-- static/locales/es.json | 31 ++++++++++++++++++++++++++-- static/locales/fa.json | 31 ++++++++++++++++++++++++++-- static/locales/fr.json | 10 +++++++++ static/locales/hi.json | 31 ++++++++++++++++++++++++++-- static/locales/it.json | 31 ++++++++++++++++++++++++++-- static/locales/ja.json | 31 ++++++++++++++++++++++++++-- static/locales/ko.json | 31 ++++++++++++++++++++++++++-- static/locales/nl.json | 31 ++++++++++++++++++++++++++-- static/locales/pl.json | 31 ++++++++++++++++++++++++++-- static/locales/pt.json | 31 ++++++++++++++++++++++++++-- static/locales/ru.json | 31 ++++++++++++++++++++++++++-- static/locales/zh-TW.json | 31 ++++++++++++++++++++++++++-- static/locales/zh.json | 31 ++++++++++++++++++++++++++-- static/sw.js | 2 +- 18 files changed, 422 insertions(+), 30 deletions(-) diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js index 9b707434..d2a2707b 100644 --- a/static/js/components/mySharesList.js +++ b/static/js/components/mySharesList.js @@ -471,7 +471,7 @@ class MySharesList { menu.appendChild(this._menuSeparator()); menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry)); menu.appendChild(this._menuSeparator()); - const removeIcon = grant.subject_type === 'group' ? 'fas fa-user-group' : 'fas fa-user-times'; + const removeIcon = grant.subject_type === 'group' ? 'fas fa-user-group' : 'fas fa-user-xmark'; menu.appendChild( this._menuItem(removeIcon, i18n.t('myshares.removeAccess', 'Remove access'), true, async () => { menu.remove(); diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 0bc61c40..241e5dc5 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -478,6 +478,10 @@ const OxiIcons = { 640, 'M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM504 312l0-64-64 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l64 0 0-64c0-13.3 10.7-24 24-24s24 10.7 24 24l0 64 64 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-64 0 0 64c0 13.3-10.7 24-24 24s-24-10.7-24-24z' ], + 'user-xmark': [ + 576, + 'M254.1 304c98.5 0 178.3 79.8 178.3 178.3 0 16.4-13.3 29.7-29.7 29.7L46.1 512c-16.4 0-29.7-13.3-29.7-29.7 0-98.5 79.8-178.3 178.3-178.3l59.4 0zM530.3 108.1c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-33.9 33.9 33.9 33.9c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-33.9-33.9-33.9 33.9c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l33.9-33.9-33.9-33.9c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l33.9 33.9 33.9-33.9zM224.4 248a120 120 0 1 1 0-240 120 120 0 1 1 0 240z' + ], users: [ 640, 'M144 0a80 80 0 1 1 0 160A80 80 0 1 1 144 0zM512 0a80 80 0 1 1 0 160A80 80 0 1 1 512 0zM0 298.7C0 239.8 47.8 192 106.7 192l42.7 0c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96c-.2 0-.4 0-.7 0L21.3 320C9.6 320 0 310.4 0 298.7zM405.3 320c-.2 0-.4 0-.7 0c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7l42.7 0C592.2 192 640 239.8 640 298.7c0 11.8-9.6 21.3-21.3 21.3l-213.3 0zM224 224a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zM128 485.3C128 411.7 187.7 352 261.3 352l117.3 0C452.3 352 512 411.7 512 485.3c0 14.7-11.9 26.7-26.7 26.7l-330.7 0c-14.7 0-26.7-11.9-26.7-26.7z' diff --git a/static/locales/ar.json b/static/locales/ar.json index dec9df29..e19778d3 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -196,7 +196,8 @@ "shareCopied": "تم نسخ الرابط إلى الحافظة", "shareCreated": "تم إنشاء رابط المشاركة بنجاح", "shareUpdated": "تم تحديث إعدادات المشاركة بنجاح", - "shareRemoved": "تمت إزالة المشاركة بنجاح" + "shareRemoved": "تمت إزالة المشاركة بنجاح", + "inviteByEmail": "دعوة عبر البريد الإلكتروني — ستُرسل الدعوة" }, "share_dialogTitle": "رابط المشاركة", "share_linkLabel": "رابط المشاركة:", @@ -697,7 +698,23 @@ "migration_verify_passed": "اجتاز التحقق", "migration_verify_failed": "فشل التحقق", "migration_failed_blobs": "كتل فاشلة", - "testing": "جارٍ الاختبار..." + "testing": "جارٍ الاختبار...", + "smtp_disabled": "معطّل (المضيف غير مضبوط)", + "smtp_enabled": "مفعّل", + "smtp_enabled_label": "الحالة", + "smtp_intro": "يتم تكوين SMTP حصريًا عبر متغيرات البيئة (OXICLOUD_SMTP_*). تُقرأ القيم أدناه من الخادم قيد التشغيل — لتغييرها، عدّل البيئة وأعد تشغيل OxiCloud.", + "smtp_not_configured": "SMTP غير مكوَّن على هذا الخادم.", + "smtp_send_failed": "فشل الإرسال.", + "smtp_send_test": "إرسال بريد اختباري", + "smtp_sending": "جارٍ الإرسال…", + "smtp_sent": "تم إرسال البريد الاختباري.", + "smtp_server_code": "رد الخادم", + "smtp_test_intro": "يرسل رسالة تشخيصية محددة مسبقًا إلى المستلم أدناه ويُبلِّغ عن استجابة خادم SMTP لتتمكن من مطابقتها مع سجلات المرحّل الخاص بك.", + "smtp_test_missing_to": "أدخل عنوان المستلم.", + "smtp_test_title": "إرسال بريد اختباري", + "smtp_test_to": "عنوان المستلم", + "smtp_title": "البريد الصادر (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "الملف الشخصي", @@ -850,5 +867,15 @@ "delete_confirm_label": "اكتب اسم المجموعة للتأكيد:", "delete_confirm_mismatch": "اكتب اسم المجموعة كما هو للتأكيد.", "virtual_internal_name": "داخلي" + }, + "myshares": { + "copyLink": "نسخ الرابط", + "deleteLink": "حذف الرابط", + "notifyByEmail": "إشعار عبر البريد الإلكتروني", + "notifyFailed": "تعذّر إرسال الإشعار.", + "notifyGroupMembers": "إشعار أعضاء المجموعة", + "notifyRateLimited": "عدد كبير من الإشعارات لهذا المستلم — حاول لاحقًا.", + "removeAccess": "إزالة الوصول", + "resendInvitation": "إعادة إرسال بريد الدعوة" } } diff --git a/static/locales/de.json b/static/locales/de.json index f2ad3726..8b3fb33e 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -196,7 +196,8 @@ "shareCopied": "Link in Zwischenablage kopiert", "shareCreated": "Freigabelink erfolgreich erstellt", "shareUpdated": "Freigabeeinstellungen aktualisiert", - "shareRemoved": "Freigabe erfolgreich entfernt" + "shareRemoved": "Freigabe erfolgreich entfernt", + "inviteByEmail": "Per E-Mail einladen — Einladung wird gesendet" }, "share_dialogTitle": "Link teilen", "share_linkLabel": "Geteilter Link:", @@ -697,7 +698,23 @@ "migration_verify_passed": "Verifizierung erfolgreich", "migration_verify_failed": "Verifizierung fehlgeschlagen", "migration_failed_blobs": "Fehlgeschlagene Blobs", - "testing": "Wird getestet..." + "testing": "Wird getestet...", + "smtp_disabled": "Deaktiviert (Host nicht gesetzt)", + "smtp_enabled": "Aktiviert", + "smtp_enabled_label": "Status", + "smtp_intro": "SMTP wird ausschließlich über Umgebungsvariablen (OXICLOUD_SMTP_*) konfiguriert. Die folgenden Werte werden aus dem laufenden Server gelesen — zum Ändern bearbeiten Sie die Umgebung und starten OxiCloud neu.", + "smtp_not_configured": "SMTP ist auf diesem Server nicht konfiguriert.", + "smtp_send_failed": "Senden fehlgeschlagen.", + "smtp_send_test": "Test-E-Mail senden", + "smtp_sending": "Senden …", + "smtp_sent": "Test-E-Mail gesendet.", + "smtp_server_code": "Server antwortete", + "smtp_test_intro": "Sendet eine fest einprogrammierte Diagnosenachricht an den unten angegebenen Empfänger und meldet die Antwort des SMTP-Servers, sodass Sie sie mit Ihren Relay-Protokollen abgleichen können.", + "smtp_test_missing_to": "Geben Sie eine Empfängeradresse ein.", + "smtp_test_title": "Test-E-Mail senden", + "smtp_test_to": "Empfängeradresse", + "smtp_title": "Ausgehende E-Mail (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "Profil", @@ -850,5 +867,15 @@ "delete_confirm_label": "Tippe den Gruppennamen zur Bestätigung ein:", "delete_confirm_mismatch": "Tippe den Gruppennamen exakt zur Bestätigung ein.", "virtual_internal_name": "Intern" + }, + "myshares": { + "copyLink": "Link kopieren", + "deleteLink": "Link löschen", + "notifyByEmail": "Per E-Mail benachrichtigen", + "notifyFailed": "Benachrichtigung konnte nicht gesendet werden.", + "notifyGroupMembers": "Gruppenmitglieder benachrichtigen", + "notifyRateLimited": "Zu viele Benachrichtigungen für diesen Empfänger — versuchen Sie es später erneut.", + "removeAccess": "Zugriff entfernen", + "resendInvitation": "Einladungs-E-Mail erneut senden" } } diff --git a/static/locales/es.json b/static/locales/es.json index 7b459ab3..06b99b5b 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -150,7 +150,8 @@ "shareCopied": "Enlace copiado al portapapeles", "shareCreated": "Enlace compartido creado correctamente", "shareUpdated": "Configuración de compartido actualizada", - "shareRemoved": "Compartido eliminado correctamente" + "shareRemoved": "Compartido eliminado correctamente", + "inviteByEmail": "Invitar por correo — se enviará una invitación" }, "share_dialogTitle": "Compartir Enlace", "share_linkLabel": "Enlace compartido:", @@ -697,7 +698,23 @@ "migration_verify_passed": "Verificación exitosa", "migration_verify_failed": "Verificación fallida", "migration_failed_blobs": "blobs fallidos", - "testing": "Probando…" + "testing": "Probando…", + "smtp_disabled": "Desactivado (host no configurado)", + "smtp_enabled": "Activado", + "smtp_enabled_label": "Estado", + "smtp_intro": "SMTP se configura exclusivamente a través de variables de entorno (OXICLOUD_SMTP_*). Los valores siguientes se leen del servidor en ejecución — para modificarlos, edita el entorno y reinicia OxiCloud.", + "smtp_not_configured": "SMTP no está configurado en este servidor.", + "smtp_send_failed": "Fallo al enviar.", + "smtp_send_test": "Enviar correo de prueba", + "smtp_sending": "Enviando…", + "smtp_sent": "Correo de prueba enviado.", + "smtp_server_code": "Respuesta del servidor", + "smtp_test_intro": "Envía un mensaje de diagnóstico predefinido al destinatario indicado abajo e informa de la respuesta del servidor SMTP para que puedas cruzarla con los registros de tu relay.", + "smtp_test_missing_to": "Introduce una dirección de destinatario.", + "smtp_test_title": "Enviar correo de prueba", + "smtp_test_to": "Dirección del destinatario", + "smtp_title": "Correo saliente (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "Perfil", @@ -850,5 +867,15 @@ "delete_confirm_label": "Escribe el nombre del grupo para confirmar:", "delete_confirm_mismatch": "Escribe el nombre del grupo exactamente para confirmar.", "virtual_internal_name": "Interno" + }, + "myshares": { + "copyLink": "Copiar enlace", + "deleteLink": "Eliminar enlace", + "notifyByEmail": "Notificar por correo", + "notifyFailed": "No se pudo enviar la notificación.", + "notifyGroupMembers": "Notificar a los miembros del grupo", + "notifyRateLimited": "Demasiadas notificaciones para este destinatario — inténtalo más tarde.", + "removeAccess": "Quitar acceso", + "resendInvitation": "Reenviar correo de invitación" } } diff --git a/static/locales/fa.json b/static/locales/fa.json index 1e7dc968..9c385666 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -196,7 +196,8 @@ "shareCopied": "پیوند به بُریده‌دان رونوشت شد", "shareCreated": "پیوند هم‌رسانی با موفقیت ایجاد شد", "shareUpdated": "تنظیمات هم‌رسانی با موفقیت به‌روزرسانی شد", - "shareRemoved": "هم‌رسانی با موفقیت پاک شد" + "shareRemoved": "هم‌رسانی با موفقیت پاک شد", + "inviteByEmail": "دعوت از طریق ایمیل — دعوت ارسال خواهد شد" }, "share_dialogTitle": "پیوند هم‌رسانی", "share_linkLabel": "پیوند هم‌رسانی:", @@ -680,7 +681,23 @@ "migration_verify_passed": "تأیید موفق", "migration_verify_failed": "تأیید ناموفق", "migration_failed_blobs": "بلوب‌های ناموفق", - "testing": "در حال آزمایش..." + "testing": "در حال آزمایش...", + "smtp_disabled": "غیرفعال (میزبان تنظیم نشده)", + "smtp_enabled": "فعال", + "smtp_enabled_label": "وضعیت", + "smtp_intro": "SMTP فقط از طریق متغیرهای محیطی (OXICLOUD_SMTP_*) پیکربندی می‌شود. مقادیر زیر از سرور در حال اجرا خوانده می‌شوند — برای تغییر آن‌ها، محیط را ویرایش کرده و OxiCloud را راه‌اندازی مجدد کنید.", + "smtp_not_configured": "SMTP روی این سرور پیکربندی نشده است.", + "smtp_send_failed": "ارسال ناموفق.", + "smtp_send_test": "ارسال ایمیل آزمایشی", + "smtp_sending": "در حال ارسال…", + "smtp_sent": "ایمیل آزمایشی ارسال شد.", + "smtp_server_code": "پاسخ سرور", + "smtp_test_intro": "یک پیام تشخیصی از پیش تعریف‌شده را به گیرنده زیر ارسال می‌کند و پاسخ سرور SMTP را گزارش می‌دهد تا بتوانید آن را با گزارش‌های ریلی خود مطابقت دهید.", + "smtp_test_missing_to": "آدرس گیرنده را وارد کنید.", + "smtp_test_title": "ارسال ایمیل آزمایشی", + "smtp_test_to": "آدرس گیرنده", + "smtp_title": "ایمیل خروجی (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "پروفایل", @@ -850,5 +867,15 @@ "delete_confirm_label": "نام گروه را برای تأیید وارد کنید:", "delete_confirm_mismatch": "نام گروه را دقیقاً برای تأیید وارد کنید.", "virtual_internal_name": "داخلی" + }, + "myshares": { + "copyLink": "کپی پیوند", + "deleteLink": "حذف پیوند", + "notifyByEmail": "اطلاع‌رسانی از طریق ایمیل", + "notifyFailed": "ارسال اعلان ممکن نشد.", + "notifyGroupMembers": "اطلاع‌رسانی به اعضای گروه", + "notifyRateLimited": "اعلان‌های زیادی برای این گیرنده — بعداً دوباره تلاش کنید.", + "removeAccess": "حذف دسترسی", + "resendInvitation": "ارسال مجدد ایمیل دعوت" } } diff --git a/static/locales/fr.json b/static/locales/fr.json index 10b4d59e..ef1e7296 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -867,5 +867,15 @@ "delete_confirm_label": "Tapez le nom du groupe pour confirmer :", "delete_confirm_mismatch": "Tapez le nom du groupe exactement pour confirmer.", "virtual_internal_name": "Interne" + }, + "myshares": { + "copyLink": "Copier le lien", + "deleteLink": "Supprimer le lien", + "notifyByEmail": "Notifier par e-mail", + "notifyFailed": "Impossible d'envoyer la notification.", + "notifyGroupMembers": "Notifier les membres du groupe", + "notifyRateLimited": "Trop de notifications pour ce destinataire — réessayez plus tard.", + "removeAccess": "Retirer l'accès", + "resendInvitation": "Renvoyer l'e-mail d'invitation" } } diff --git a/static/locales/hi.json b/static/locales/hi.json index 32d7aee4..9e604d36 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -196,7 +196,8 @@ "shareCopied": "लिंक क्लिपबोर्ड पर कॉपी हुआ", "shareCreated": "शेयर लिंक सफलतापूर्वक बनाया गया", "shareUpdated": "शेयर सेटिंग्स सफलतापूर्वक अपडेट हुईं", - "shareRemoved": "शेयर सफलतापूर्वक हटाया गया" + "shareRemoved": "शेयर सफलतापूर्वक हटाया गया", + "inviteByEmail": "ईमेल द्वारा आमंत्रित करें — आमंत्रण भेजा जाएगा" }, "share_dialogTitle": "शेयर लिंक", "share_linkLabel": "शेयर लिंक:", @@ -697,7 +698,23 @@ "migration_verify_passed": "सत्यापन पास", "migration_verify_failed": "सत्यापन विफल", "migration_failed_blobs": "विफल ब्लॉब्स", - "testing": "परीक्षण हो रहा है..." + "testing": "परीक्षण हो रहा है...", + "smtp_disabled": "अक्षम (होस्ट सेट नहीं)", + "smtp_enabled": "सक्षम", + "smtp_enabled_label": "स्थिति", + "smtp_intro": "SMTP केवल पर्यावरण चर (OXICLOUD_SMTP_*) के माध्यम से कॉन्फ़िगर किया जाता है। नीचे दिए गए मान चल रहे सर्वर से पढ़े जाते हैं — उन्हें बदलने के लिए, पर्यावरण संपादित करें और OxiCloud को पुनः आरंभ करें।", + "smtp_not_configured": "इस सर्वर पर SMTP कॉन्फ़िगर नहीं है।", + "smtp_send_failed": "भेजना विफल।", + "smtp_send_test": "परीक्षण ईमेल भेजें", + "smtp_sending": "भेजा जा रहा है…", + "smtp_sent": "परीक्षण ईमेल भेजा गया।", + "smtp_server_code": "सर्वर का उत्तर", + "smtp_test_intro": "नीचे दिए गए प्राप्तकर्ता को एक पूर्व-निर्धारित निदान संदेश भेजता है और SMTP सर्वर का उत्तर रिपोर्ट करता है ताकि आप इसे अपने रिले लॉग्स से मिला सकें।", + "smtp_test_missing_to": "प्राप्तकर्ता पता दर्ज करें।", + "smtp_test_title": "परीक्षण ईमेल भेजें", + "smtp_test_to": "प्राप्तकर्ता का पता", + "smtp_title": "जावक ईमेल (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "प्रोफ़ाइल", @@ -850,5 +867,15 @@ "delete_confirm_label": "पुष्टि के लिए समूह का नाम लिखें:", "delete_confirm_mismatch": "पुष्टि के लिए समूह का नाम बिल्कुल वैसा ही लिखें।", "virtual_internal_name": "आंतरिक" + }, + "myshares": { + "copyLink": "लिंक कॉपी करें", + "deleteLink": "लिंक हटाएँ", + "notifyByEmail": "ईमेल से सूचित करें", + "notifyFailed": "सूचना नहीं भेजी जा सकी।", + "notifyGroupMembers": "समूह के सदस्यों को सूचित करें", + "notifyRateLimited": "इस प्राप्तकर्ता के लिए बहुत अधिक सूचनाएँ — बाद में पुनः प्रयास करें।", + "removeAccess": "पहुँच हटाएँ", + "resendInvitation": "आमंत्रण ईमेल पुनः भेजें" } } diff --git a/static/locales/it.json b/static/locales/it.json index 0348bb7f..759eb053 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -196,7 +196,8 @@ "shareCopied": "Link copiato negli appunti", "shareCreated": "Link di condivisione creato con successo", "shareUpdated": "Impostazioni di condivisione aggiornate con successo", - "shareRemoved": "Condivisione rimossa con successo" + "shareRemoved": "Condivisione rimossa con successo", + "inviteByEmail": "Invita via email — verrà inviato un invito" }, "share_dialogTitle": "Link di condivisione", "share_linkLabel": "Link di condivisione:", @@ -697,7 +698,23 @@ "migration_verify_passed": "Verifica superata", "migration_verify_failed": "Verifica fallita", "migration_failed_blobs": "Blob falliti", - "testing": "Test in corso..." + "testing": "Test in corso...", + "smtp_disabled": "Disabilitato (host non impostato)", + "smtp_enabled": "Abilitato", + "smtp_enabled_label": "Stato", + "smtp_intro": "SMTP è configurato esclusivamente tramite variabili d'ambiente (OXICLOUD_SMTP_*). I valori sottostanti sono letti dal server in esecuzione — per modificarli, modifica l'ambiente e riavvia OxiCloud.", + "smtp_not_configured": "SMTP non è configurato su questo server.", + "smtp_send_failed": "Invio non riuscito.", + "smtp_send_test": "Invia email di prova", + "smtp_sending": "Invio in corso…", + "smtp_sent": "Email di prova inviata.", + "smtp_server_code": "Risposta del server", + "smtp_test_intro": "Invia un messaggio diagnostico predefinito al destinatario indicato sotto e riporta la risposta del server SMTP, così puoi correlarla con i log del tuo relay.", + "smtp_test_missing_to": "Inserisci un indirizzo destinatario.", + "smtp_test_title": "Invia un'email di prova", + "smtp_test_to": "Indirizzo destinatario", + "smtp_title": "Email in uscita (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "Profilo", @@ -850,5 +867,15 @@ "delete_confirm_label": "Digita il nome del gruppo per confermare:", "delete_confirm_mismatch": "Digita esattamente il nome del gruppo per confermare.", "virtual_internal_name": "Interno" + }, + "myshares": { + "copyLink": "Copia link", + "deleteLink": "Elimina link", + "notifyByEmail": "Notifica via email", + "notifyFailed": "Impossibile inviare la notifica.", + "notifyGroupMembers": "Notifica i membri del gruppo", + "notifyRateLimited": "Troppe notifiche per questo destinatario — riprova più tardi.", + "removeAccess": "Rimuovi accesso", + "resendInvitation": "Reinvia email di invito" } } diff --git a/static/locales/ja.json b/static/locales/ja.json index 7e3951ff..7e7388c8 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -196,7 +196,8 @@ "shareCopied": "リンクがクリップボードにコピーされました", "shareCreated": "共有リンクが正常に作成されました", "shareUpdated": "共有設定が正常に更新されました", - "shareRemoved": "共有が正常に削除されました" + "shareRemoved": "共有が正常に削除されました", + "inviteByEmail": "メールで招待 — 招待を送信します" }, "share_dialogTitle": "共有リンク", "share_linkLabel": "共有リンク:", @@ -697,7 +698,23 @@ "migration_verify_passed": "検証に合格", "migration_verify_failed": "検証に失敗", "migration_failed_blobs": "失敗したブロブ", - "testing": "テスト中..." + "testing": "テスト中...", + "smtp_disabled": "無効 (ホスト未設定)", + "smtp_enabled": "有効", + "smtp_enabled_label": "ステータス", + "smtp_intro": "SMTP は環境変数 (OXICLOUD_SMTP_*) でのみ設定します。以下の値は稼働中のサーバーから読み取られます — 変更するには環境を編集して OxiCloud を再起動してください。", + "smtp_not_configured": "このサーバーでは SMTP が設定されていません。", + "smtp_send_failed": "送信に失敗しました。", + "smtp_send_test": "テストメールを送信", + "smtp_sending": "送信中…", + "smtp_sent": "テストメールを送信しました。", + "smtp_server_code": "サーバーの応答", + "smtp_test_intro": "あらかじめ定義された診断メッセージを下記の宛先に送信し、SMTP サーバーの応答を表示します。これを使ってリレーのログと突き合わせて確認できます。", + "smtp_test_missing_to": "宛先アドレスを入力してください。", + "smtp_test_title": "テストメールを送信", + "smtp_test_to": "宛先アドレス", + "smtp_title": "送信メール (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "プロフィール", @@ -850,5 +867,15 @@ "delete_confirm_label": "確認のためにグループ名を入力してください:", "delete_confirm_mismatch": "確認のためにグループ名を正確に入力してください。", "virtual_internal_name": "内部" + }, + "myshares": { + "copyLink": "リンクをコピー", + "deleteLink": "リンクを削除", + "notifyByEmail": "メールで通知", + "notifyFailed": "通知を送信できませんでした。", + "notifyGroupMembers": "グループメンバーに通知", + "notifyRateLimited": "この受信者への通知が多すぎます — しばらくしてから再試行してください。", + "removeAccess": "アクセスを削除", + "resendInvitation": "招待メールを再送信" } } diff --git a/static/locales/ko.json b/static/locales/ko.json index fd71c784..3cdee5ef 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -196,7 +196,8 @@ "shareCopied": "링크가 클립보드에 복사되었습니다", "shareCreated": "공유 링크가 성공적으로 생성되었습니다", "shareUpdated": "공유 설정이 성공적으로 업데이트되었습니다", - "shareRemoved": "공유가 성공적으로 삭제되었습니다" + "shareRemoved": "공유가 성공적으로 삭제되었습니다", + "inviteByEmail": "이메일로 초대 — 초대장이 전송됩니다" }, "share_dialogTitle": "공유 링크", "share_linkLabel": "공유 링크:", @@ -697,7 +698,23 @@ "migration_verify_passed": "확인 통과", "migration_verify_failed": "확인 실패", "migration_failed_blobs": "실패한 블롭", - "testing": "테스트 중..." + "testing": "테스트 중...", + "smtp_disabled": "비활성화됨 (호스트 미설정)", + "smtp_enabled": "활성화됨", + "smtp_enabled_label": "상태", + "smtp_intro": "SMTP는 환경 변수(OXICLOUD_SMTP_*)로만 구성됩니다. 아래 값들은 실행 중인 서버에서 읽어옵니다 — 변경하려면 환경을 수정하고 OxiCloud를 다시 시작하세요.", + "smtp_not_configured": "이 서버에는 SMTP가 구성되어 있지 않습니다.", + "smtp_send_failed": "전송 실패.", + "smtp_send_test": "테스트 이메일 보내기", + "smtp_sending": "보내는 중…", + "smtp_sent": "테스트 이메일을 보냈습니다.", + "smtp_server_code": "서버 응답", + "smtp_test_intro": "아래 수신자에게 미리 정의된 진단 메시지를 보내고 SMTP 서버의 응답을 표시합니다. 이를 통해 릴레이 로그와 대조하여 확인할 수 있습니다.", + "smtp_test_missing_to": "수신자 주소를 입력하세요.", + "smtp_test_title": "테스트 이메일 보내기", + "smtp_test_to": "수신자 주소", + "smtp_title": "발신 이메일 (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "프로필", @@ -850,5 +867,15 @@ "delete_confirm_label": "확인을 위해 그룹 이름을 입력하세요:", "delete_confirm_mismatch": "확인을 위해 그룹 이름을 정확히 입력하세요.", "virtual_internal_name": "내부" + }, + "myshares": { + "copyLink": "링크 복사", + "deleteLink": "링크 삭제", + "notifyByEmail": "이메일로 알림", + "notifyFailed": "알림을 보낼 수 없습니다.", + "notifyGroupMembers": "그룹 구성원에게 알림", + "notifyRateLimited": "이 수신자에게 알림이 너무 많습니다 — 나중에 다시 시도하세요.", + "removeAccess": "액세스 제거", + "resendInvitation": "초대 이메일 다시 보내기" } } diff --git a/static/locales/nl.json b/static/locales/nl.json index f7de5637..8a5f7f90 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -196,7 +196,8 @@ "shareCopied": "Link gekopieerd naar klembord", "shareCreated": "Deellink succesvol aangemaakt", "shareUpdated": "Deelinstellingen bijgewerkt", - "shareRemoved": "Delen verwijderd" + "shareRemoved": "Delen verwijderd", + "inviteByEmail": "Uitnodigen via e-mail — uitnodiging wordt verzonden" }, "share_dialogTitle": "Deellink", "share_linkLabel": "Deellink:", @@ -697,7 +698,23 @@ "migration_verify_passed": "Verificatie geslaagd", "migration_verify_failed": "Verificatie mislukt", "migration_failed_blobs": "Mislukte blobs", - "testing": "Bezig met testen..." + "testing": "Bezig met testen...", + "smtp_disabled": "Uitgeschakeld (host niet ingesteld)", + "smtp_enabled": "Ingeschakeld", + "smtp_enabled_label": "Status", + "smtp_intro": "SMTP wordt uitsluitend geconfigureerd via omgevingsvariabelen (OXICLOUD_SMTP_*). De onderstaande waarden worden gelezen uit de actieve server — om ze te wijzigen, bewerk de omgeving en herstart OxiCloud.", + "smtp_not_configured": "SMTP is niet geconfigureerd op deze server.", + "smtp_send_failed": "Verzenden mislukt.", + "smtp_send_test": "Test-e-mail verzenden", + "smtp_sending": "Bezig met verzenden…", + "smtp_sent": "Test-e-mail verzonden.", + "smtp_server_code": "Serverantwoord", + "smtp_test_intro": "Verzendt een vooraf gedefinieerd diagnostisch bericht naar de onderstaande ontvanger en rapporteert het antwoord van de SMTP-server, zodat je het kunt correleren met je relay-logboeken.", + "smtp_test_missing_to": "Voer een ontvangeradres in.", + "smtp_test_title": "Test-e-mail verzenden", + "smtp_test_to": "Ontvangeradres", + "smtp_title": "Uitgaande e-mail (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "Profiel", @@ -850,5 +867,15 @@ "delete_confirm_label": "Typ de groepsnaam ter bevestiging:", "delete_confirm_mismatch": "Typ de groepsnaam exact om te bevestigen.", "virtual_internal_name": "Intern" + }, + "myshares": { + "copyLink": "Link kopiëren", + "deleteLink": "Link verwijderen", + "notifyByEmail": "Per e-mail notificeren", + "notifyFailed": "Notificatie kon niet worden verzonden.", + "notifyGroupMembers": "Groepsleden notificeren", + "notifyRateLimited": "Te veel notificaties voor deze ontvanger — probeer het later opnieuw.", + "removeAccess": "Toegang verwijderen", + "resendInvitation": "Uitnodigingsmail opnieuw verzenden" } } diff --git a/static/locales/pl.json b/static/locales/pl.json index f02e6783..6ce9b41a 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -196,7 +196,8 @@ "shareCopied": "Link skopiowany do schowka", "shareCreated": "Link udostępniania utworzony pomyślnie", "shareUpdated": "Ustawienia udostępniania zaktualizowane pomyślnie", - "shareRemoved": "Udostępnienie usunięte pomyślnie" + "shareRemoved": "Udostępnienie usunięte pomyślnie", + "inviteByEmail": "Zaproś przez e-mail — zaproszenie zostanie wysłane" }, "share_dialogTitle": "Link udostępniania", "share_linkLabel": "Link udostępniania:", @@ -697,7 +698,23 @@ "migration_verify_passed": "Weryfikacja zaliczona", "migration_verify_failed": "Weryfikacja nieudana", "migration_failed_blobs": "nieudane bloby", - "testing": "Testowanie…" + "testing": "Testowanie…", + "smtp_disabled": "Wyłączone (host nieustawiony)", + "smtp_enabled": "Włączone", + "smtp_enabled_label": "Status", + "smtp_intro": "SMTP jest konfigurowany wyłącznie przez zmienne środowiskowe (OXICLOUD_SMTP_*). Poniższe wartości są odczytywane z działającego serwera — aby je zmienić, zmodyfikuj środowisko i uruchom ponownie OxiCloud.", + "smtp_not_configured": "SMTP nie jest skonfigurowany na tym serwerze.", + "smtp_send_failed": "Wysłanie nie powiodło się.", + "smtp_send_test": "Wyślij e-mail testowy", + "smtp_sending": "Wysyłanie…", + "smtp_sent": "E-mail testowy wysłany.", + "smtp_server_code": "Odpowiedź serwera", + "smtp_test_intro": "Wysyła wstępnie zdefiniowaną wiadomość diagnostyczną do podanego poniżej odbiorcy i raportuje odpowiedź serwera SMTP, abyś mógł skorelować ją z logami swojego przekaźnika.", + "smtp_test_missing_to": "Wprowadź adres odbiorcy.", + "smtp_test_title": "Wyślij e-mail testowy", + "smtp_test_to": "Adres odbiorcy", + "smtp_title": "Poczta wychodząca (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "Profil", @@ -850,5 +867,15 @@ "delete_confirm_label": "Wpisz nazwę grupy, aby potwierdzić:", "delete_confirm_mismatch": "Wpisz nazwę grupy dokładnie, aby potwierdzić.", "virtual_internal_name": "Wewnętrzni" + }, + "myshares": { + "copyLink": "Skopiuj link", + "deleteLink": "Usuń link", + "notifyByEmail": "Powiadom e-mailem", + "notifyFailed": "Nie udało się wysłać powiadomienia.", + "notifyGroupMembers": "Powiadom członków grupy", + "notifyRateLimited": "Zbyt wiele powiadomień dla tego odbiorcy — spróbuj ponownie później.", + "removeAccess": "Usuń dostęp", + "resendInvitation": "Wyślij ponownie e-mail z zaproszeniem" } } diff --git a/static/locales/pt.json b/static/locales/pt.json index 9fd032db..cba6ffa4 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -196,7 +196,8 @@ "shareCopied": "Link copiado para a área de transferência", "shareCreated": "Link de compartilhamento criado com sucesso", "shareUpdated": "Configurações de compartilhamento atualizadas", - "shareRemoved": "Compartilhamento removido com sucesso" + "shareRemoved": "Compartilhamento removido com sucesso", + "inviteByEmail": "Convidar por e-mail — o convite será enviado" }, "share_dialogTitle": "Link de compartilhamento", "share_linkLabel": "Link compartilhado:", @@ -697,7 +698,23 @@ "migration_verify_passed": "Verificação aprovada", "migration_verify_failed": "Verificação falhou", "migration_failed_blobs": "Blobs com falha", - "testing": "A testar..." + "testing": "A testar...", + "smtp_disabled": "Desativado (host não configurado)", + "smtp_enabled": "Ativado", + "smtp_enabled_label": "Estado", + "smtp_intro": "SMTP é configurado exclusivamente através de variáveis de ambiente (OXICLOUD_SMTP_*). Os valores abaixo são lidos do servidor em execução — para alterá-los, edite o ambiente e reinicie o OxiCloud.", + "smtp_not_configured": "SMTP não está configurado neste servidor.", + "smtp_send_failed": "Falha no envio.", + "smtp_send_test": "Enviar e-mail de teste", + "smtp_sending": "A enviar…", + "smtp_sent": "E-mail de teste enviado.", + "smtp_server_code": "Resposta do servidor", + "smtp_test_intro": "Envia uma mensagem de diagnóstico pré-definida para o destinatário abaixo e reporta a resposta do servidor SMTP, para que possa correlacioná-la com os registos do seu relay.", + "smtp_test_missing_to": "Introduza um endereço de destinatário.", + "smtp_test_title": "Enviar um e-mail de teste", + "smtp_test_to": "Endereço do destinatário", + "smtp_title": "E-mail de saída (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "Perfil", @@ -850,5 +867,15 @@ "delete_confirm_label": "Digite o nome do grupo para confirmar:", "delete_confirm_mismatch": "Digite o nome do grupo exatamente para confirmar.", "virtual_internal_name": "Interno" + }, + "myshares": { + "copyLink": "Copiar link", + "deleteLink": "Eliminar link", + "notifyByEmail": "Notificar por e-mail", + "notifyFailed": "Não foi possível enviar a notificação.", + "notifyGroupMembers": "Notificar membros do grupo", + "notifyRateLimited": "Demasiadas notificações para este destinatário — tente novamente mais tarde.", + "removeAccess": "Remover acesso", + "resendInvitation": "Reenviar e-mail de convite" } } diff --git a/static/locales/ru.json b/static/locales/ru.json index a5fe8d17..c6d6d802 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -196,7 +196,8 @@ "shareCopied": "Ссылка скопирована в буфер обмена", "shareCreated": "Ссылка для общего доступа успешно создана", "shareUpdated": "Настройки общего доступа успешно обновлены", - "shareRemoved": "Общий доступ успешно удалён" + "shareRemoved": "Общий доступ успешно удалён", + "inviteByEmail": "Пригласить по e-mail — приглашение будет отправлено" }, "share_dialogTitle": "Ссылка для обмена", "share_linkLabel": "Ссылка:", @@ -697,7 +698,23 @@ "migration_verify_passed": "Проверка пройдена", "migration_verify_failed": "Проверка не пройдена", "migration_failed_blobs": "Неудачные блобы", - "testing": "Тестирование..." + "testing": "Тестирование...", + "smtp_disabled": "Отключено (хост не задан)", + "smtp_enabled": "Включено", + "smtp_enabled_label": "Статус", + "smtp_intro": "SMTP настраивается исключительно через переменные окружения (OXICLOUD_SMTP_*). Значения ниже считываются с работающего сервера — чтобы изменить их, отредактируйте окружение и перезапустите OxiCloud.", + "smtp_not_configured": "SMTP не настроен на этом сервере.", + "smtp_send_failed": "Сбой отправки.", + "smtp_send_test": "Отправить тестовое письмо", + "smtp_sending": "Отправка…", + "smtp_sent": "Тестовое письмо отправлено.", + "smtp_server_code": "Ответ сервера", + "smtp_test_intro": "Отправляет заранее заданное диагностическое сообщение указанному ниже получателю и сообщает ответ SMTP-сервера, чтобы вы могли сопоставить его с журналами вашего relay.", + "smtp_test_missing_to": "Введите адрес получателя.", + "smtp_test_title": "Отправить тестовое письмо", + "smtp_test_to": "Адрес получателя", + "smtp_title": "Исходящая почта (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "Профиль", @@ -850,5 +867,15 @@ "delete_confirm_label": "Введите имя группы для подтверждения:", "delete_confirm_mismatch": "Введите имя группы точно для подтверждения.", "virtual_internal_name": "Внутренние" + }, + "myshares": { + "copyLink": "Копировать ссылку", + "deleteLink": "Удалить ссылку", + "notifyByEmail": "Уведомить по e-mail", + "notifyFailed": "Не удалось отправить уведомление.", + "notifyGroupMembers": "Уведомить участников группы", + "notifyRateLimited": "Слишком много уведомлений для этого получателя — попробуйте позже.", + "removeAccess": "Отозвать доступ", + "resendInvitation": "Отправить приглашение повторно" } } diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 85dbc0bc..45516f41 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -196,7 +196,8 @@ "shareCopied": "連結已複製到剪貼簿", "shareCreated": "共享連結建立成功", "shareUpdated": "共享設定更新成功", - "shareRemoved": "共享已移除" + "shareRemoved": "共享已移除", + "inviteByEmail": "透過郵件邀請 — 將傳送邀請" }, "share_dialogTitle": "共享連結", "share_linkLabel": "共享連結:", @@ -680,7 +681,23 @@ "migration_verify_passed": "驗證透過", "migration_verify_failed": "驗證失敗", "migration_failed_blobs": "失敗的塊", - "testing": "正在測試..." + "testing": "正在測試...", + "smtp_disabled": "已停用(未設定主機)", + "smtp_enabled": "已啟用", + "smtp_enabled_label": "狀態", + "smtp_intro": "SMTP 僅透過環境變數(OXICLOUD_SMTP_*)設定。下方數值是從運行中的伺服器讀取的 — 如需修改,請編輯環境變數並重新啟動 OxiCloud。", + "smtp_not_configured": "此伺服器未設定 SMTP。", + "smtp_send_failed": "傳送失敗。", + "smtp_send_test": "傳送測試郵件", + "smtp_sending": "傳送中…", + "smtp_sent": "測試郵件已傳送。", + "smtp_server_code": "伺服器回應", + "smtp_test_intro": "向下方收件者傳送預設的診斷訊息,並回報 SMTP 伺服器的回應,以便您與轉發日誌進行對照。", + "smtp_test_missing_to": "請輸入收件者地址。", + "smtp_test_title": "傳送測試郵件", + "smtp_test_to": "收件者地址", + "smtp_title": "外寄郵件 (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "個人資料", @@ -850,5 +867,15 @@ "delete_confirm_label": "請輸入群組名稱以確認:", "delete_confirm_mismatch": "請準確輸入群組名稱以確認。", "virtual_internal_name": "內部" + }, + "myshares": { + "copyLink": "複製連結", + "deleteLink": "刪除連結", + "notifyByEmail": "透過郵件通知", + "notifyFailed": "無法傳送通知。", + "notifyGroupMembers": "通知群組成員", + "notifyRateLimited": "對此收件者的通知過多 — 請稍後重試。", + "removeAccess": "移除存取權限", + "resendInvitation": "重新傳送邀請郵件" } } diff --git a/static/locales/zh.json b/static/locales/zh.json index fb1de088..70323529 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -196,7 +196,8 @@ "shareCopied": "链接已复制到剪贴板", "shareCreated": "共享链接创建成功", "shareUpdated": "共享设置更新成功", - "shareRemoved": "共享已移除" + "shareRemoved": "共享已移除", + "inviteByEmail": "通过邮件邀请 — 将发送邀请" }, "share_dialogTitle": "共享链接", "share_linkLabel": "共享链接:", @@ -680,7 +681,23 @@ "migration_verify_passed": "验证通过", "migration_verify_failed": "验证失败", "migration_failed_blobs": "失败的块", - "testing": "正在测试..." + "testing": "正在测试...", + "smtp_disabled": "已禁用(未设置主机)", + "smtp_enabled": "已启用", + "smtp_enabled_label": "状态", + "smtp_intro": "SMTP 仅通过环境变量(OXICLOUD_SMTP_*)配置。以下值是从运行中的服务器读取的 — 如需修改,请编辑环境变量并重启 OxiCloud。", + "smtp_not_configured": "此服务器未配置 SMTP。", + "smtp_send_failed": "发送失败。", + "smtp_send_test": "发送测试邮件", + "smtp_sending": "发送中…", + "smtp_sent": "测试邮件已发送。", + "smtp_server_code": "服务器回复", + "smtp_test_intro": "向下方收件人发送预设的诊断消息,并报告 SMTP 服务器的响应,以便您与中继日志进行核对。", + "smtp_test_missing_to": "请输入收件人地址。", + "smtp_test_title": "发送测试邮件", + "smtp_test_to": "收件人地址", + "smtp_title": "出站邮件 (SMTP)", + "tab_smtp": "SMTP" }, "profile": { "page_title": "个人资料", @@ -850,5 +867,15 @@ "delete_confirm_label": "请输入群组名称以确认:", "delete_confirm_mismatch": "请准确输入群组名称以确认。", "virtual_internal_name": "内部" + }, + "myshares": { + "copyLink": "复制链接", + "deleteLink": "删除链接", + "notifyByEmail": "通过邮件通知", + "notifyFailed": "无法发送通知。", + "notifyGroupMembers": "通知群组成员", + "notifyRateLimited": "对此收件人的通知过多 — 请稍后重试。", + "removeAccess": "移除访问权限", + "resendInvitation": "重新发送邀请邮件" } } diff --git a/static/sw.js b/static/sw.js index 74befcaf..ec5542a3 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,6 +1,6 @@ // OxiCloud Service Worker // FIXME: generate cache name according build ? -const CACHE_NAME = 'oxicloud-cache-v25'; +const CACHE_NAME = 'oxicloud-cache-v26'; // Only cache static assets — NOT HTML files. // HTML files are served network-first so browsers always get the latest From ca0f5b3fc58502fdfd3331e0b9033572e6252ab2 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 10:40:18 +0200 Subject: [PATCH 4/8] fix(ui): ensure usermenu always in viewport - ensure also use of only one method to position different contextmenus --- static/js/app/ui.js | 56 ++++------- static/js/components/mySharesList.js | 13 ++- static/js/utils/menuPosition.js | 137 +++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 47 deletions(-) create mode 100644 static/js/utils/menuPosition.js diff --git a/static/js/app/ui.js b/static/js/app/ui.js index d16854e5..68828898 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -14,6 +14,7 @@ import { fileOps } from '../features/files/fileOperations.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; import { wopiEditor } from '../features/files/wopiEditor.js'; import { recent } from '../features/library/recent.js'; +import { positionMenu } from '../utils/menuPosition.js'; import { loadFiles } from './filesView.js'; import { updateHistory } from './main.js'; import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js'; @@ -915,33 +916,24 @@ const ui = { */ showContextMenuForItem(item, e) { const trigger = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-actions')); + const menuId = 'mime_type' in item ? 'file-context-menu' : 'folder-context-menu'; if ('mime_type' in item) { app.contextMenuTargetFile = /** @type {FileItem} */ (item); - if (trigger) { - showContextMenuAtElement(trigger, 'file-context-menu'); - } else { - const menu = document.getElementById('file-context-menu'); - if (menu) { - menu.style.left = `${e.pageX}px`; - menu.style.top = `${e.pageY}px`; - contextMenus.sync(); - menu.classList.remove('hidden'); - } - } } else { app.contextMenuTargetFolder = /** @type {FolderItem} */ (item); - if (trigger) { - showContextMenuAtElement(trigger, 'folder-context-menu'); - } else { - const menu = document.getElementById('folder-context-menu'); - if (menu) { - menu.style.left = `${e.pageX}px`; - menu.style.top = `${e.pageY}px`; - contextMenus.sync(); - menu.classList.remove('hidden'); - } - } } + + if (trigger) { + showContextMenuAtElement(trigger, menuId); + return; + } + // Right-click on the row body with no kebab in scope — open at + // the cursor. positionMenu() clamps into the viewport, so menus + // near the bottom of the screen no longer overflow off-screen. + const menu = /** @type {HTMLElement | null} */ (document.getElementById(menuId)); + if (!menu) return; + contextMenus.sync(); + positionMenu(menu, { x: e.pageX, y: e.pageY }); }, /** @@ -1083,27 +1075,11 @@ function showContextMenuAtElement(triggerElement, menuId) { m.classList.add('hidden'); }); - const menu = document.getElementById(menuId); + const menu = /** @type {HTMLElement | null} */ (document.getElementById(menuId)); if (!menu) return; - const rect = triggerElement.getBoundingClientRect(); - const menuWidth = 200; // approximate - - // Position below the trigger, aligned to the right edge - let left = rect.right - menuWidth + window.scrollX; - let top = rect.bottom + 4 + window.scrollY; - - // Keep inside viewport - if (left < 8) left = 8; - if (top + 300 > window.innerHeight + window.scrollY) { - top = rect.top - 4 + window.scrollY; // flip above if no room - } - contextMenus.sync(); - - menu.style.left = `${left}px`; - menu.style.top = `${top}px`; - menu.classList.remove('hidden'); + positionMenu(menu, { anchor: triggerElement }); } /** diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js index d2a2707b..e079837f 100644 --- a/static/js/components/mySharesList.js +++ b/static/js/components/mySharesList.js @@ -15,6 +15,7 @@ import { i18n } from '../core/i18n.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; import { grants } from '../model/grants.js'; import { buildExpiryChip } from '../utils/expiryChip.js'; +import { positionMenu } from '../utils/menuPosition.js'; import { buildPasswordChip } from '../utils/passwordChip.js'; import { groupDisplayName, groupIconClass } from './groupDisplay.js'; import { createGroupVignette } from './groupVignette.js'; @@ -502,13 +503,11 @@ class MySharesList { document.body.appendChild(menu); - // Position below the trigger, right-aligned to it, clamped to viewport - const rect = btn.getBoundingClientRect(); - const mw = menu.offsetWidth || 200; - const left = Math.min(rect.right - mw, window.innerWidth - mw - 8); - menu.style.position = 'absolute'; - menu.style.top = `${rect.bottom + window.scrollY + 4}px`; - menu.style.left = `${Math.max(8, left)}px`; + // Position below the trigger, flipping above (or clamping up) + // when the trigger is too close to the bottom of the viewport. + // Single source of truth for menu positioning — see + // `static/js/utils/menuPosition.js`. + positionMenu(menu, { anchor: btn }); const close = (/** @type {Event} */ e) => { if (e.type === 'keydown' && /** @type {KeyboardEvent} */ (e).key !== 'Escape') return; diff --git a/static/js/utils/menuPosition.js b/static/js/utils/menuPosition.js new file mode 100644 index 00000000..245dfc07 --- /dev/null +++ b/static/js/utils/menuPosition.js @@ -0,0 +1,137 @@ +// @ts-check + +/** + * Single positioning engine for every floating menu in the app — + * file/folder context menus, the My Shares per-row action menu, the + * batch-toolbar "more" menu, and any future overlay that needs to sit + * near a trigger button or a click point. + * + * Replaces a sprawl of ad-hoc `style.top`/`style.left` formulas, none of + * which agreed on viewport clamping. The recurring bug fixed here: + * triggers near the bottom of the screen produced menus that overflowed + * off-screen because callers set `top = rect.bottom + 4` without + * checking whether the menu actually fit below. + * + * Resolution policy (anchor target): + * 1. Try below the anchor, right-aligned by default. + * 2. If the menu would overflow the viewport bottom AND there is more + * room above than below, flip to above the anchor. + * 3. Otherwise stay below and clamp the top so the menu fits. + * 4. Horizontally: clamp into `[margin, viewport - margin]`; the + * menu may shift left so the right-aligned default isn't a strict + * invariant when the trigger is near the right edge. + * + * Point target (right-click): open below-right of the cursor, then + * apply the same clamping. No flip — the user expects the menu near + * the click. + * + * Measurement note: callers may invoke this on a menu that is still + * `display:none` (via `.hidden`). We temporarily render it + * `visibility:hidden` so `offsetWidth`/`offsetHeight` reflect the + * actual rendered size, then leave the menu visible. The caller does + * NOT need to toggle `.hidden` before or after. + */ + +/** + * @typedef {Object} AnchorTarget + * @property {HTMLElement} anchor Trigger element (e.g. the ⋯ button). + * Menu opens below it by default, + * flipping above if it doesn't fit. + */ + +/** + * @typedef {Object} PointTarget + * @property {number} x Page-space X (e.g. from `MouseEvent.pageX`). + * @property {number} y Page-space Y (e.g. from `MouseEvent.pageY`). + */ + +/** + * @typedef {Object} PositionOpts + * @property {number} [margin=8] Min gap between the menu and any viewport edge. + * @property {'right'|'left'} [align='right'] + * Anchor mode only — which edge of the menu lines up with the anchor. + * `'right'` is the typical kebab/dropdown convention. + * @property {number} [gap=4] Vertical gap between menu and anchor edge. + */ + +/** + * Position a menu so it stays inside the viewport, anchored to a + * trigger element or a click point. The menu is left visible (its + * `.hidden` class, if present, is removed) and the caller can attach + * dismiss handlers as usual. + * + * @param {HTMLElement} menu + * @param {AnchorTarget | PointTarget} target + * @param {PositionOpts} [opts] + */ +export function positionMenu(menu, target, opts = {}) { + const margin = opts.margin ?? 8; + const gap = opts.gap ?? 4; + const align = opts.align ?? 'right'; + + // Ensure layout so we can measure. The caller may have passed a + // .hidden menu; render it invisibly first. + const wasHidden = menu.classList.contains('hidden'); + let restoreVisibility = null; + if (wasHidden) { + restoreVisibility = menu.style.visibility; + menu.style.visibility = 'hidden'; + menu.classList.remove('hidden'); + } + + // offsetWidth/Height fall back to a sane minimum if the menu has + // no content yet (shouldn't happen in practice; defensive only). + const mw = menu.offsetWidth || 200; + const mh = menu.offsetHeight || 200; + const vw = window.innerWidth; + const vh = window.innerHeight; + const sx = window.scrollX; + const sy = window.scrollY; + + let left; + let top; + + if ('anchor' in target) { + const rect = target.anchor.getBoundingClientRect(); + // Horizontal: right- or left-edge alignment with the trigger, + // converted to page space. + left = align === 'right' ? rect.right - mw + sx : rect.left + sx; + + // Vertical: prefer below; flip above when it doesn't fit and + // there's more room above. Both edges are still clamped below + // — the flip is a preference, not an absolute. + const spaceBelow = vh - rect.bottom; + const spaceAbove = rect.top; + const shouldFlip = mh + gap > spaceBelow && spaceAbove > spaceBelow; + top = shouldFlip ? rect.top - mh - gap + sy : rect.bottom + gap + sy; + } else { + left = target.x; + top = target.y; + } + + // Horizontal clamp. + const minLeft = sx + margin; + const maxLeft = sx + vw - mw - margin; + if (left > maxLeft) left = maxLeft; + if (left < minLeft) left = minLeft; + + // Vertical clamp. Fixes the off-screen bug: even after the + // anchor-flip heuristic, a very tall menu can still overflow the + // viewport. Push it up so its bottom edge sits at `viewport - + // margin`; if that pushes the top off the viewport, surrender and + // clamp at the top edge (the menu is taller than the viewport). + const minTop = sy + margin; + const maxTop = sy + vh - mh - margin; + if (top > maxTop) top = maxTop; + if (top < minTop) top = minTop; + + menu.style.position = 'absolute'; + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + // Restore visibility (we want the menu shown, since the caller is + // about to wire its dismiss handlers). + if (wasHidden) { + menu.style.visibility = restoreVisibility ?? ''; + } +} From 540c947e61e8144d7ca9528e0b18a5b7ac7b214c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 11:15:44 +0200 Subject: [PATCH 5/8] fix(ui): better handling of new folder creation issue: on folder creation view was wiped and displaying only the new folder fix: add a "new" swimlane if in group mode and scroll up to the new created folder --- static/css/components/resourceList.css | 39 +++++++ static/js/app/filesView.js | 14 ++- static/js/components/resourceList.js | 156 ++++++++++++++++++++++++- static/locales/ar.json | 3 +- static/locales/de.json | 3 +- static/locales/en.json | 3 +- static/locales/es.json | 3 +- static/locales/fa.json | 3 +- static/locales/fr.json | 3 +- static/locales/hi.json | 3 +- static/locales/it.json | 3 +- static/locales/ja.json | 3 +- static/locales/ko.json | 3 +- static/locales/nl.json | 3 +- static/locales/pl.json | 3 +- static/locales/pt.json | 3 +- static/locales/ru.json | 3 +- static/locales/zh-TW.json | 3 +- static/locales/zh.json | 3 +- 19 files changed, 233 insertions(+), 24 deletions(-) diff --git a/static/css/components/resourceList.css b/static/css/components/resourceList.css index 94ee1e0e..5b280ce7 100644 --- a/static/css/components/resourceList.css +++ b/static/css/components/resourceList.css @@ -41,6 +41,45 @@ background-color: var(--color-item); } +/* Brief pulse on rows that were just optimistically inserted (e.g. + newly created folder, upload completion, drag-drop move into the + current folder). Pure CSS so timing is deterministic — the JS adds + the class, the animation auto-clears the background, and a + single `animationend` listener removes the class. + + `scroll-margin-top` reserves space above the row for the sticky + page header (`.page-sticky-header` ≈ 80 px). Without it, + `scrollIntoView({ block: 'nearest' })` aligns the row's top edge + against the viewport's top edge — which the sticky header is + currently covering — so the user only sees the row's bottom edge. + `scroll-margin-bottom` gives a touch of breathing room when the + scroll happens to land the row near the viewport bottom. */ +.file-item.resource-row--just-added { + animation: resource-row-just-added 1.5s ease-out; + scroll-margin-top: 100px; + scroll-margin-bottom: 24px; +} + +@keyframes resource-row-just-added { + 0% { + background-color: var(--color-success-bg); + } + + 100% { + background-color: transparent; + } +} + +/* Client-only "New" swimlane created on the fly by `addItem()` when + the view is grouped. Subtler styling than a natural-group lane: the + user understands the pin is temporary (it dissolves on next full + reload), so we don't want the bar to dominate the list. The pinned + placement at the top of the container is what makes it + discoverable; the header just confirms the intent. */ +.resource-list__swimlane-group--just-added > .resource-list__swimlane-header { + color: var(--color-success-text); +} + .file-item.selected { background-color: var(--color-item-selected); } diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index d25d4d13..5f7893be 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -366,9 +366,17 @@ async function _loadPage({ isFirstPage = false } = {}) { function addItem(item) { const component = _ensureComponent(); if (!component) return; - // Reveal the list if the empty-state is showing - ui.resetFilesList(); - component.addItem(item); + // Hide the empty-state placeholder if it's currently showing — + // creating a folder in an empty directory should reveal the new + // row, not display both states side-by-side. (The component will + // un-hide `#files-list` itself when the item is inserted.) + document.getElementById('files-container-error')?.classList.add('hidden'); + // Hand the item to the component so it can place it in the right + // swimlane (when the current view is grouped) and pulse-highlight + // + smooth-scroll it into view. We deliberately do NOT call + // `ui.resetFilesList()` here — that wipes the rendered DOM, + // defeating the whole point of an optimistic single-item insert. + component.addItem(item, { scroll: true, highlight: true }); } /** diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 56e39b8b..0578be27 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -28,6 +28,15 @@ import { createUserVignette } from './userVignette.js'; * @import {FileItem, FolderItem} from '../core/types.js' */ +/** + * Reusable swimlane key for the client-side "just added" lane that + * `addItem()` opens at the top of the list in grouped views. Distinct + * from any natural group key the server might produce so the lookup + * can't collide with a real bucket whose label happens to read "New". + * @type {string} + */ +const JUST_ADDED_KEY = '__oxicloud_just_added__'; + /** * @typedef {Object} CustomAction * @property {string} iconHtml - Inner HTML for the button icon (e.g. ``). @@ -131,6 +140,16 @@ export class ResourceListComponent { */ this._lastGroupEl = null; + /** + * Optional grouping-key resolver stored between `render()` / `append()` + * calls so `addItem()` can place a new row in the correct swimlane + * without the caller having to re-supply it. `undefined` means the + * current view is flat (no group-by); `null` is never stored — only + * function or `undefined`. + * @type {((item: FileItem|FolderItem) => string|null) | undefined} + */ + this._groupFn = undefined; + /** * Optional label-resolver stored between `render()` and `append()` calls. * @type {((key: string) => string) | undefined} @@ -184,6 +203,7 @@ export class ResourceListComponent { // Reset group tracking for the fresh render this._lastGroupKey = undefined; this._lastGroupEl = null; + this._groupFn = groupFn; this._groupLabelFn = groupLabelFn; this._headerNodeFn = headerNodeFn; @@ -205,7 +225,13 @@ export class ResourceListComponent { * @param {((key: string) => HTMLElement)=} headerNodeFn */ append(items, groupFn, groupLabelFn, headerNodeFn) { - this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn); + // Persist the latest non-undefined callbacks so `addItem()` can + // reuse them without the caller having to re-supply them on every + // optimistic insertion. + if (groupFn !== undefined) this._groupFn = groupFn; + if (groupLabelFn !== undefined) this._groupLabelFn = groupLabelFn; + if (headerNodeFn !== undefined) this._headerNodeFn = headerNodeFn; + this._appendItems(items, groupFn ?? this._groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn); } /** Remove all items (but keep `.list-header` if present). */ @@ -218,6 +244,9 @@ export class ResourceListComponent { this._lastClickedIndex = -1; this._lastGroupKey = undefined; this._lastGroupEl = null; + this._groupFn = undefined; + this._groupLabelFn = undefined; + this._headerNodeFn = undefined; // Hand delegation back to ui.js delete this._container.dataset.managedBy; } @@ -276,16 +305,128 @@ export class ResourceListComponent { /** * Append a single item, skipping silently if already present (duplicate guard). * Clears the empty-state placeholder when the first item is added. + * + * Group-by aware: if the current view is grouped, the row goes into + * a dedicated **"New" swimlane pinned at the top of the list** that + * is created on first call and reused across subsequent inserts in + * the same session. This deliberately sidesteps re-computing the + * item's natural bucket on the client: + * + * - Different group-by dimensions (date, type, size, …) would each + * need their own resolver, and date-bucket math is sensitive to + * clock skew between client and server. + * - Cross-swimlane sort-position is impossible to mirror exactly + * without re-implementing the server's tiebreaker chain. + * + * The "New" lane is purely client-side and dissolves on the next + * full reload (when the server's authoritative grouping reasserts). + * Predictable and uniform across every group-by mode. + * * @param {FileItem|FolderItem} item + * @param {{ scroll?: boolean, highlight?: boolean }} [opts] + * - `scroll`: smooth-scroll the new row into view. The + * `.resource-row--just-added` class also sets `scroll-margin` + * so the sticky page header doesn't cover the row. + * - `highlight`: flash a brief CSS pulse on the new row so the + * user can spot it amid similar siblings. + * @returns {HTMLElement | null} The inserted row, or `null` when the + * item was deduped. */ - addItem(item) { - if (this._items.has(item.id)) return; + addItem(item, opts = {}) { + if (this._items.has(item.id)) return null; // Also guard against stale DOM remnants not tracked in _items const isFile = 'mime_type' in item; const attr = isFile ? `data-file-id="${item.id}"` : `data-folder-id="${item.id}"`; - if (this._container.querySelector(`.file-item[${attr}]`)) return; + if (this._container.querySelector(`.file-item[${attr}]`)) return null; this._container.classList.remove('hidden'); - this._appendItems([item]); + + /** @type {HTMLElement | null} */ + let row = null; + + if (this._groupFn) { + // Grouped view → drop the new row into the top-of-list + // "New" swimlane, creating it on first call. + const lane = this._ensureJustAddedLane(); + this._items.set(item.id, item); + row = isFile ? this._createFileItem(/** @type {FileItem} */ (item)) : this._createFolderItem(/** @type {FolderItem} */ (item)); + lane.appendChild(row); + } else { + // Flat list (no grouping) — append at the end like before. + this._appendItems([item]); + row = /** @type {HTMLElement | null} */ (this._container.querySelector(`.file-item[${attr}]`)); + } + + if (!row) return null; + + if (opts.highlight) { + row.classList.add('resource-row--just-added'); + // Self-cleaning: drop the class once the keyframe completes + // so a future re-render starts from a neutral baseline. + row.addEventListener('animationend', () => row?.classList.remove('resource-row--just-added'), { once: true }); + } + if (opts.scroll) { + // `block: 'nearest'` is a no-op when the row is already in + // view. `.resource-row--just-added` sets `scroll-margin-top` + // so the sticky page header doesn't clip the row when the + // scroll lands. + row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + + return row; + } + + /** + * Ensure the top-of-list "New" swimlane exists and return its + * wrapper. Created on first call after a render; reused across + * subsequent `addItem()` calls in the same session. The lane + * dissolves on the next `render()` / `clear()`, at which point + * the server's authoritative grouping reasserts. + * + * Header label uses i18n key `groupby.justAdded` with an English + * fallback so views that haven't translated it still read sensibly. + * + * @returns {HTMLElement} + */ + _ensureJustAddedLane() { + const existing = this._findLaneByKey(JUST_ADDED_KEY); + if (existing) return existing; + + const lane = document.createElement('div'); + lane.className = 'resource-list__swimlane-group resource-list__swimlane-group--just-added'; + lane.dataset.groupKey = JUST_ADDED_KEY; + + const header = document.createElement('div'); + header.className = 'resource-list__swimlane-header'; + header.dataset.swimlaneHeader = 'true'; + header.textContent = i18n.t('groupby.justAdded', 'New'); + lane.appendChild(header); + + // Insert at the very top of the container, immediately after + // the optional `.list-header` row, so the affordance is + // discoverable and the row's scroll-into-view brings the + // swimlane header into view too. + const listHeader = this._container.querySelector('.list-header'); + if (listHeader?.nextSibling) { + this._container.insertBefore(lane, listHeader.nextSibling); + } else if (listHeader) { + this._container.appendChild(lane); + } else { + this._container.prepend(lane); + } + return lane; + } + + /** + * Locate an on-screen swimlane wrapper by its group key. Returns + * `null` when no swimlane currently matches. + * + * @param {string} key + * @returns {HTMLElement | null} + */ + _findLaneByKey(key) { + // CSS.escape covers arbitrary key shapes (dates with colons, + // UUIDs with dashes, etc.) so the attribute selector is safe. + return /** @type {HTMLElement | null} */ (this._container.querySelector(`.resource-list__swimlane-group[data-group-key="${CSS.escape(String(key))}"]`)); } /** @@ -404,6 +545,11 @@ export class ResourceListComponent { if (key !== null) { fragmentGroup = document.createElement('div'); fragmentGroup.className = 'resource-list__swimlane-group'; + // Stamp the group key on the wrapper so `addItem()` + // can locate this swimlane later via + // `_findLaneByKey()` and append into it without a + // full re-render. + fragmentGroup.dataset.groupKey = key; fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn)); fragment.appendChild(fragmentGroup); } diff --git a/static/locales/ar.json b/static/locales/ar.json index e19778d3..fa1cb9c3 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -835,7 +835,8 @@ "size": "الحجم", "favoriteDate": "تاريخ المفضلة", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "جديد" }, "dateBucket": { "today": "اليوم", diff --git a/static/locales/de.json b/static/locales/de.json index 8b3fb33e..984cadf2 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -835,7 +835,8 @@ "size": "Größe", "favoriteDate": "Datum der Markierung", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Neu" }, "dateBucket": { "today": "Heute", diff --git a/static/locales/en.json b/static/locales/en.json index 4ed01aef..a606b0fa 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -845,7 +845,8 @@ "accessedAt": "Accessed date", "modifiedAt": "Modified date", "createdAt": "Created date", - "size": "Size" + "size": "Size", + "justAdded": "New" }, "dateBucket": { "today": "Today", diff --git a/static/locales/es.json b/static/locales/es.json index 06b99b5b..01657b03 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -835,7 +835,8 @@ "size": "Tamaño", "favoriteDate": "Fecha de favorito", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nuevo" }, "dateBucket": { "today": "Hoy", diff --git a/static/locales/fa.json b/static/locales/fa.json index 9c385666..61ab2a56 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -835,7 +835,8 @@ "size": "اندازه", "favoriteDate": "تاریخ مورد علاقه", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "جدید" }, "dateBucket": { "today": "امروز", diff --git a/static/locales/fr.json b/static/locales/fr.json index ef1e7296..51c4afa5 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -835,7 +835,8 @@ "createdAt": "Date de création", "size": "Taille", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nouveau" }, "dateBucket": { "today": "Aujourd'hui", diff --git a/static/locales/hi.json b/static/locales/hi.json index 9e604d36..62b4dda1 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -835,7 +835,8 @@ "size": "आकार", "favoriteDate": "पसंदीदा की तारीख", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "नया" }, "dateBucket": { "today": "आज", diff --git a/static/locales/it.json b/static/locales/it.json index 759eb053..0d3860d2 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -835,7 +835,8 @@ "size": "Dimensione", "favoriteDate": "Data preferito", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nuovo" }, "dateBucket": { "today": "Oggi", diff --git a/static/locales/ja.json b/static/locales/ja.json index 7e7388c8..84debb3f 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -835,7 +835,8 @@ "size": "サイズ", "favoriteDate": "お気に入り登録日", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "新規" }, "dateBucket": { "today": "今日", diff --git a/static/locales/ko.json b/static/locales/ko.json index 3cdee5ef..345cbe13 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -835,7 +835,8 @@ "size": "크기", "favoriteDate": "즐겨찾기 날짜", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "새 항목" }, "dateBucket": { "today": "오늘", diff --git a/static/locales/nl.json b/static/locales/nl.json index 8a5f7f90..33f93ec1 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -835,7 +835,8 @@ "size": "Grootte", "favoriteDate": "Favoritendatum", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nieuw" }, "dateBucket": { "today": "Vandaag", diff --git a/static/locales/pl.json b/static/locales/pl.json index 6ce9b41a..e9fff114 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -835,7 +835,8 @@ "size": "Rozmiar", "favoriteDate": "Data dodania do ulubionych", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nowe" }, "dateBucket": { "today": "Dzisiaj", diff --git a/static/locales/pt.json b/static/locales/pt.json index cba6ffa4..358ba879 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -835,7 +835,8 @@ "size": "Tamanho", "favoriteDate": "Data de favorito", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Novo" }, "dateBucket": { "today": "Hoje", diff --git a/static/locales/ru.json b/static/locales/ru.json index c6d6d802..c2b1cfec 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -835,7 +835,8 @@ "size": "Размер", "favoriteDate": "Дата добавления в избранное", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Новые" }, "dateBucket": { "today": "Сегодня", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 45516f41..5d98739c 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -835,7 +835,8 @@ "size": "大小", "favoriteDate": "收藏日期", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "新增" }, "dateBucket": { "today": "今天", diff --git a/static/locales/zh.json b/static/locales/zh.json index 70323529..428ce842 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -835,7 +835,8 @@ "size": "大小", "favoriteDate": "收藏日期", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "新建" }, "dateBucket": { "today": "今天", From 6f9e12624bc9c53e2a562b40d84f0631c276fab2 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 14:01:00 +0200 Subject: [PATCH 6/8] chore(tools) add script to audit translations --- tools/check-icons.py | 66 ++++++-- tools/check-missing-translations.py | 247 ++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+), 18 deletions(-) mode change 100644 => 100755 tools/check-icons.py create mode 100755 tools/check-missing-translations.py diff --git a/tools/check-icons.py b/tools/check-icons.py old mode 100644 new mode 100755 index c6742bda..4bcce954 --- a/tools/check-icons.py +++ b/tools/check-icons.py @@ -3,9 +3,18 @@ check-icons.py — Audit FA icon usage against the inline SVG registry. Usage: - python3 tools/check-icons.py [--dry-run] + python3 tools/check-icons.py [--dry-run] [--check-only] -What it does: +Modes: + (default) Scan, diff, and **patch** icons.js — clones Font-Awesome + to tmp/ if missing so SVG paths can be resolved. + --dry-run Same as default but print the proposed insertions + instead of writing icons.js. + --check-only CI-friendly: just scan + diff and exit with status 1 + if any used FA icon is absent from OxiIcons. No + Font-Awesome clone, no file writes, no SVG parsing. + +What the full mode does: 1. Scans every file under static/ for fas fa- occurrences. 2. Reads the OxiIcons registry from static/js/core/icons.js. 3. For each icon name that is missing from the registry, looks up @@ -29,25 +38,30 @@ STATIC_DIR = REPO_ROOT / "static" ICONS_JS = STATIC_DIR / "js" / "core" / "icons.js" FA_SVG_DIR = REPO_ROOT / "tmp" / "Font-Awesome" / "svgs" / "solid" -DRY_RUN = "--dry-run" in sys.argv +DRY_RUN = "--dry-run" in sys.argv +CHECK_ONLY = "--check-only" in sys.argv # ── 0. Ensure Font-Awesome source is available ──────────────────────────────── -TMP_DIR = REPO_ROOT / "tmp" -if not TMP_DIR.exists(): - print(f"Creating {TMP_DIR.relative_to(REPO_ROOT)}/") - TMP_DIR.mkdir(parents=True, exist_ok=True) +# Skipped in --check-only mode — that path stops after the diff (step 3) +# so it never needs to resolve SVG sources. This keeps CI runs offline, +# fast, and free of clone side effects in the checkout dir. +if not CHECK_ONLY: + TMP_DIR = REPO_ROOT / "tmp" + if not TMP_DIR.exists(): + print(f"Creating {TMP_DIR.relative_to(REPO_ROOT)}/") + TMP_DIR.mkdir(parents=True, exist_ok=True) -FA_REPO = TMP_DIR / "Font-Awesome" -if not FA_REPO.exists(): - print(f"Font-Awesome not found at {FA_REPO.relative_to(REPO_ROOT)} — cloning …") - result = subprocess.run( - ["git", "clone", "https://github.com/FortAwesome/Font-Awesome.git", str(FA_REPO)], - check=False, - ) - if result.returncode != 0: - print("✗ git clone failed — cannot continue without Font-Awesome source.") - sys.exit(1) - print("✓ Font-Awesome cloned successfully.\n") + FA_REPO = TMP_DIR / "Font-Awesome" + if not FA_REPO.exists(): + print(f"Font-Awesome not found at {FA_REPO.relative_to(REPO_ROOT)} — cloning …") + result = subprocess.run( + ["git", "clone", "https://github.com/FortAwesome/Font-Awesome.git", str(FA_REPO)], + check=False, + ) + if result.returncode != 0: + print("✗ git clone failed — cannot continue without Font-Awesome source.") + sys.exit(1) + print("✓ Font-Awesome cloned successfully.\n") # ── 1. Scan static/ for all fas fa- occurrences ─────────────────────── FA_RE = re.compile(r'\bfas fa-([\w-]+)') @@ -104,6 +118,22 @@ if not missing: print(f"\n{len(missing)} missing icon(s):") +# ── 3b. CI gate ─────────────────────────────────────────────────────────────── +# In --check-only mode we report the missing names and stop here. The +# default mode continues into the SVG-resolve + patch path below. +if CHECK_ONLY: + for name, files in sorted(missing.items()): + print(f" • {name:30s} used in: {', '.join(files)}") + print( + f"\n✗ {len(missing)} icon(s) referenced in static/ are absent from " + f"OxiIcons in {ICONS_JS.relative_to(REPO_ROOT)}." + ) + print( + " Run `python3 tools/check-icons.py` locally (without " + "--check-only) to auto-add them from Font-Awesome." + ) + sys.exit(1) + # ── 4. Resolve each missing icon from FA SVG files ──────────────────────────── VIEWBOX_RE = re.compile(r'viewBox="0 0 (\d+) (\d+)"') PATH_D_RE = re.compile(r']+\bd="([^"]+)"') diff --git a/tools/check-missing-translations.py b/tools/check-missing-translations.py new file mode 100755 index 00000000..97163ecc --- /dev/null +++ b/tools/check-missing-translations.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +check-missing-translations.py — Audit locale files against en.json. + +Treats static/locales/en.json as the canonical key set. For every other +JSON file in static/locales/, reports keys that are missing (present in +en.json, absent here) and optionally keys that are extra (present here, +absent in en.json — usually drift from a removed feature). + +Exit code: + 0 — every non-English locale has every English key + 1 — at least one locale is missing one or more keys + +Usage: + python3 tools/check-missing-translations.py [options] + +Options: + --check-only CI-friendly: print per-locale counts only + (no per-key list). Exit code is unchanged — + the script always returns 1 on any miss, + this flag just keeps the CI log terse. + Mirrors `tools/check-icons.py --check-only`. + --no-extras Suppress the "extra keys" section. + --values Show the English source value next to each + missing key (truncated to 80 chars). + --locale CODE [CODE…] Audit only the listed locale(s) (e.g. fr de). + Default: every non-English file in the dir. + +Examples: + # Verbose audit of every locale, including extras + python3 tools/check-missing-translations.py + + # CI mode — terse output, exit 1 on any miss + python3 tools/check-missing-translations.py --check-only + + # Just French and Spanish, with English values to help translators + python3 tools/check-missing-translations.py --locale fr es --values +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +LOCALES_DIR = REPO_ROOT / "static" / "locales" +SOURCE_LOCALE = "en" + +# Truncation length for the English source value shown by --values. +VALUE_PREVIEW_LEN = 80 + + +def flatten(obj: dict[str, Any], prefix: str = "") -> dict[str, Any]: + """Walk a nested JSON object and produce a flat {"dotted.key": value} + dict. Non-dict leaves (strings, numbers, booleans, arrays) are kept + as-is; only nested dicts are expanded into the key path.""" + out: dict[str, Any] = {} + for key, value in obj.items(): + full = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + out.update(flatten(value, full)) + else: + out[full] = value + return out + + +def truncate(text: str, max_len: int) -> str: + """Visual truncation for terminal output. Newlines normalised to + spaces so a multi-line email body still fits on one row.""" + one_line = text.replace("\n", " ").replace("\r", " ") + if len(one_line) <= max_len: + return one_line + return one_line[: max_len - 1] + "…" + + +def load_locale(path: Path) -> dict[str, Any]: + """Parse one locale file. Returns the flattened key set.""" + try: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print(f"✗ {path.name}: could not parse ({exc})", file=sys.stderr) + sys.exit(1) + if not isinstance(data, dict): + print(f"✗ {path.name}: root must be an object", file=sys.stderr) + sys.exit(1) + return flatten(data) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Audit static/locales/*.json against en.json. Reports keys " + "missing from each non-English locale and (optionally) keys " + "that exist in non-English but not in English." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--check-only", + action="store_true", + help=( + "CI mode: print per-locale counts only, not the full key " + "list. Exit code is unchanged (1 on any miss). Mirrors " + "`tools/check-icons.py --check-only`." + ), + ) + parser.add_argument( + "--no-extras", + action="store_true", + help="Suppress the 'extra keys' section (default: report them).", + ) + parser.add_argument( + "--values", + action="store_true", + help=( + "Print the English source value next to each missing key. " + "Helpful for translators; ignored under --check-only." + ), + ) + parser.add_argument( + "--locale", + nargs="+", + metavar="CODE", + help=( + "Audit only the given locale code(s) (e.g. 'fr', 'zh-TW'). " + "Default: every *.json in static/locales/ except en.json." + ), + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if not LOCALES_DIR.is_dir(): + print(f"✗ Locales directory not found: {LOCALES_DIR}", file=sys.stderr) + return 1 + + source_path = LOCALES_DIR / f"{SOURCE_LOCALE}.json" + if not source_path.exists(): + print(f"✗ Source locale not found: {source_path}", file=sys.stderr) + return 1 + + en = load_locale(source_path) + en_keys = set(en.keys()) + + # Decide which locales to audit. --locale narrows the set; otherwise + # we audit everything except en.json. + if args.locale: + locale_paths = [] + for code in args.locale: + p = LOCALES_DIR / f"{code}.json" + if not p.exists(): + print(f"✗ Locale file not found: {p}", file=sys.stderr) + return 1 + if code == SOURCE_LOCALE: + print( + f"⚠ Skipping --locale {code}: that's the source locale.", + file=sys.stderr, + ) + continue + locale_paths.append(p) + else: + locale_paths = sorted( + p + for p in LOCALES_DIR.glob("*.json") + if p.stem != SOURCE_LOCALE + ) + + if not locale_paths: + print("Nothing to check.") + return 0 + + print(f"Source: {source_path.relative_to(REPO_ROOT)} ({len(en_keys)} keys)\n") + + total_missing = 0 + total_extra = 0 + locale_with_missing: list[str] = [] + + for path in locale_paths: + loc = load_locale(path) + loc_keys = set(loc.keys()) + missing = sorted(en_keys - loc_keys) + extra = sorted(loc_keys - en_keys) + total_missing += len(missing) + total_extra += len(extra) + if missing: + locale_with_missing.append(path.stem) + + mark = "✓" if not missing else "✗" + suffix = "" + if missing or extra: + parts = [] + if missing: + parts.append(f"missing={len(missing)}") + if extra: + parts.append(f"extra={len(extra)}") + suffix = " " + " ".join(parts) + print(f" {mark} {path.name:14} total={len(loc_keys)}{suffix}") + + if args.check_only: + continue + + # Per-key listing (suppressed under --check-only). + if missing: + print(f" missing ({len(missing)}):") + for key in missing: + if args.values: + val = en.get(key, "") + if not isinstance(val, str): + val = json.dumps(val, ensure_ascii=False) + preview = truncate(val, VALUE_PREVIEW_LEN) + print(f" - {key} :: {preview}") + else: + print(f" - {key}") + if extra and not args.no_extras: + print(f" extra ({len(extra)}):") + for key in extra: + print(f" + {key}") + + # ── Trailer ──────────────────────────────────────────────────────── + print() + if total_missing == 0: + print(f"✓ Every locale is at parity with {SOURCE_LOCALE}.json.") + if total_extra and not args.no_extras: + print( + f" (Note: {total_extra} extra key(s) across locales — " + f"they don't fail the check but may indicate drift.)" + ) + return 0 + + print( + f"✗ {total_missing} missing translation(s) across " + f"{len(locale_with_missing)} locale(s): " + f"{', '.join(locale_with_missing)}" + ) + if total_extra and not args.no_extras: + print(f" Plus {total_extra} extra key(s); see per-locale output above.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 49f9a14ef0a4fb384901398da422a6da1a5c3239 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 14:01:59 +0200 Subject: [PATCH 7/8] chore(ui): complete missing icons via tools/check-icons.py --- static/js/core/icons.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 241e5dc5..66420925 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -505,6 +505,23 @@ const OxiIcons = { world: [ 512, 'M351.9 280l-190.9 0c2.9 64.5 17.2 123.9 37.5 167.4 11.4 24.5 23.7 41.8 35.1 52.4 11.2 10.5 18.9 12.2 22.9 12.2s11.7-1.7 22.9-12.2c11.4-10.6 23.7-28 35.1-52.4 20.3-43.5 34.6-102.9 37.5-167.4zM160.9 232l190.9 0C349 167.5 334.7 108.1 314.4 64.6 303 40.2 290.7 22.8 279.3 12.2 268.1 1.7 260.4 0 256.4 0s-11.7 1.7-22.9 12.2c-11.4 10.6-23.7 28-35.1 52.4-20.3 43.5-34.6 102.9-37.5 167.4zm-48 0C116.4 146.4 138.5 66.9 170.8 14.7 78.7 47.3 10.9 131.2 1.5 232l111.4 0zM1.5 280c9.4 100.8 77.2 184.7 169.3 217.3-32.3-52.2-54.4-131.7-57.9-217.3L1.5 280zm398.4 0c-3.5 85.6-25.6 165.1-57.9 217.3 92.1-32.7 159.9-116.5 169.3-217.3l-111.4 0zm111.4-48C501.9 131.2 434.1 47.3 342 14.7 374.3 66.9 396.4 146.4 399.9 232l111.4 0z' + ], + + 'exchange-alt': [ + 512, + 'M502.6 150.6l-96 96c-9.2 9.2-22.9 11.9-34.9 6.9S352 236.9 352 224l0-64-320 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l320 0 0-64c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l96 96c12.5 12.5 12.5 32.8 0 45.3zm-397.3 352l-96-96c-12.5-12.5-12.5-32.8 0-45.3l96-96c9.2-9.2 22.9-11.9 34.9-6.9S160 275.1 160 288l0 64 320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-320 0 0 64c0 12.9-7.8 24.6-19.8 29.6s-25.7 2.2-34.9-6.9z' + ], + 'flag-checkered': [ + 448, + 'M32 0C49.7 0 64 14.3 64 32l0 16 69-17.2c38.1-9.5 78.3-5.1 113.5 12.5 46.3 23.2 100.8 23.2 147.1 0l9.6-4.8C423.8 28.1 448 43.1 448 66.1l0 279.7c0 13.3-8.3 25.3-20.8 30l-34.7 13c-46.2 17.3-97.6 14.6-141.7-7.4-37.9-19-81.4-23.7-122.5-13.4L64 384 64 480c0 17.7-14.3 32-32 32S0 497.7 0 480L0 32C0 14.3 14.3 0 32 0zM64 187.1l64-13.9 0 65.5-64 13.9 0 65.5 48.8-12.2c5.1-1.3 10.1-2.4 15.2-3.3l0-63.9 38.9-8.4c8.3-1.8 16.7-2.5 25.1-2.1l0-64c13.6 .4 27.2 2.6 40.4 6.4l23.6 6.9 0 66.7-41.7-12.3c-7.3-2.1-14.8-3.4-22.3-3.8l0 71.4c21.8 1.9 43.3 6.7 64 14.4l0-69.8 22.7 6.7c13.5 4 27.3 6.4 41.3 7.4l0-64.2c-7.8-.8-15.6-2.3-23.2-4.5l-40.8-12 0-62c-13-3.8-25.8-8.8-38.2-15-8.2-4.1-16.9-7-25.8-8.8l0 72.4c-13-.4-26 .8-38.7 3.6l-25.3 5.5 0-75.2-64 16 0 73.1zM320 335.7c16.8 1.5 33.9-.7 50-6.8l14-5.2 0-71.7-7.9 1.8c-18.4 4.3-37.3 5.7-56.1 4.5l0 77.4zm64-149.4l0-70.8c-20.9 6.1-42.4 9.1-64 9.1l0 69.4c13.9 1.4 28 .5 41.7-2.6l22.3-5.2z' + ], + 'id-badge': [ + 384, + 'M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-384c0-35.3-28.7-64-64-64L64 0zm96 352l64 0c44.2 0 80 35.8 80 80 0 8.8-7.2 16-16 16L96 448c-8.8 0-16-7.2-16-16 0-44.2 35.8-80 80-80zm-24-96a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zM152 64l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z' + ], + 'lock-open': [ + 576, + 'M384 96c0-35.3 28.7-64 64-64s64 28.7 64 64l0 32c0 17.7 14.3 32 32 32s32-14.3 32-32l0-32c0-70.7-57.3-128-128-128S320 25.3 320 96l0 64-160 0c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64l-32 0 0-64z' ] }; From 8b08b081c58f2cae0b2a94e6c6a77d95c50a2d8b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 14:18:27 +0200 Subject: [PATCH 8/8] chore(ci): check icons and i18n --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ justfile | 11 +++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8713edb0..8ccaa391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,10 @@ jobs: - '.grit' - '.stylelintrc.json' - 'jsconfig.json' + # Audit scripts that gate frontend correctness — touch + # them and the frontend job must re-run. + - 'tools/check-missing-translations.py' + - 'tools/check-icons.py' backend: - 'src/**' - 'Cargo.toml' @@ -75,6 +79,24 @@ jobs: - name: Run TypeScript check run: tsc -p jsconfig.json --noEmit + - name: Check locale files are at parity with en.json + # Python 3 stdlib only — no setup step needed. + # `--check-only` keeps the CI log terse; failures still surface + # via exit code (the script returns 1 when any non-English + # locale is missing a key present in en.json). Mirrors the + # `--check-only` flag on `tools/check-icons.py` below. + # Run locally without --check-only to see the missing keys. + run: python3 tools/check-missing-translations.py --check-only + + - name: Check FA icons referenced in static/ are registered + # `--check-only` skips the Font-Awesome clone and the icons.js + # patch — it just scans `fas fa-` references and diffs + # them against OxiIcons. Exit 1 if any used icon is absent + # from the registry. Run locally without --check-only to + # auto-add missing entries from a checked-out Font-Awesome + # source. + run: python3 tools/check-icons.py --check-only + rust-fmt: name: Rustfmt needs: changes diff --git a/justfile b/justfile index 9e77e0b1..5b1a9ba0 100644 --- a/justfile +++ b/justfile @@ -74,8 +74,8 @@ db-down: front-dev: PROFILE=dev cargo run -# front: check all (linter, format, type, ...) -front-check: front-fmt front-lint front-type front-rules +# front: check all (linter, format, type, icons, translations...) +front-check: front-fmt front-lint front-type front-rules front-check-icons front-check-i18n front-fmt: biome format static/ @@ -91,6 +91,13 @@ front-type: front-rules: stylelint static/css/ +front-check-icons: + tools/check-icons.py --check-only + +front-check-i18n: + tools/check-missing-translations.py --check-only + + # end-to-end Playwright tests front-test: cd tests/e2e && npm test