Merge branch 'main' into french_translation
This commit is contained in:
@@ -64,10 +64,7 @@ describe('admin mutate-based endpoints', () => {
|
||||
describe('admin read endpoints', () => {
|
||||
it('call apiJson for the listing/settings reads', async () => {
|
||||
await admin.listUsers(25, 0);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
'/api/admin/users?limit=25&offset=0&summary=true',
|
||||
expect.anything()
|
||||
);
|
||||
expect(jsonMock).toHaveBeenCalledWith('/api/admin/users?limit=25&offset=0', expect.anything());
|
||||
await admin.getDashboard();
|
||||
await admin.getSmtpInfo();
|
||||
await admin.getOidcSettings();
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
DriveMember,
|
||||
DriveMemberSubject,
|
||||
DriveRole,
|
||||
User
|
||||
FullUser
|
||||
} from '$lib/api/types';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
@@ -277,19 +277,24 @@ export function revokeAdminSession(sessionId: string): Promise<void> {
|
||||
|
||||
// ── Users ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** List the compact rows rendered by the management table; full account
|
||||
* details remain available through {@link getUserAdmin}. */
|
||||
/** List admin users — always returns `FullUser` rows. The former
|
||||
* `?summary` toggle is retired; a single canonical shape carries
|
||||
* the vignette + admin-visible extras the table needs. Single-user
|
||||
* details still available via {@link getUserAdmin}. */
|
||||
export function listUsers(limit: number, offset: number): Promise<AdminUsersPage> {
|
||||
return apiJson<AdminUsersPage>(`/api/admin/users?limit=${limit}&offset=${offset}&summary=true`, {
|
||||
return apiJson<AdminUsersPage>(`/api/admin/users?limit=${limit}&offset=${offset}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-scoped single-user lookup — `GET /api/admin/users/{id}`.
|
||||
* Returns the full `User` DTO including `storage_quota_bytes` +
|
||||
* `storage_used_bytes` which the non-admin `/api/users/{id}`
|
||||
* response omits for privacy.
|
||||
* Returns the full `FullUser` DTO (public identity in `.user` +
|
||||
* admin-visible extras like `email_verified_at` / `has_password` /
|
||||
* `opaque_registered` / `last_login_at` / quotas at top level) —
|
||||
* same shape as one row of `/api/admin/users` list. The peer-view
|
||||
* `/api/users/{id}` returns the slim `PublicUser` which omits those
|
||||
* admin-only signals.
|
||||
*
|
||||
* Result promises are cached per id at module scope so multiple
|
||||
* callers for the same user (e.g. the admin drives table with N
|
||||
@@ -301,14 +306,14 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
|
||||
* still sees the cached value. Callers that need to refresh (e.g.
|
||||
* after `setUserQuota`) should call `invalidateAdminUserCache`.
|
||||
*/
|
||||
const adminUserCache = new Map<string, Promise<User | null>>();
|
||||
const adminUserCache = new Map<string, Promise<FullUser | null>>();
|
||||
|
||||
export function getUserAdmin(id: string): Promise<User | null> {
|
||||
export function getUserAdmin(id: string): Promise<FullUser | null> {
|
||||
const hit = adminUserCache.get(id);
|
||||
if (hit) return hit;
|
||||
const pending = (async (): Promise<User | null> => {
|
||||
const pending = (async (): Promise<FullUser | null> => {
|
||||
try {
|
||||
return await apiJson<User>(`/api/admin/users/${encodeURIComponent(id)}`, {
|
||||
return await apiJson<FullUser>(`/api/admin/users/${encodeURIComponent(id)}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch {
|
||||
@@ -387,9 +392,30 @@ export interface DriveKindUsage {
|
||||
}
|
||||
|
||||
export interface AdminDashboard {
|
||||
// ── User accounts (static breakdown of auth.users) ──
|
||||
// All four are counts of the same table under different
|
||||
// predicates. Rendered as one grouped section on the dashboard.
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
admin_users: number;
|
||||
/** Grant-only accounts (magic-link / OIDC-only / OCM recipients).
|
||||
* Filtered out of `total_users` / `active_users` — those count
|
||||
* operational seats. Surfaced here as its own metric because
|
||||
* external-heavy deployments (public-share collab, invited-only
|
||||
* shops) need the invited population at a glance. */
|
||||
external_users: number;
|
||||
// ── Live activity (projection over auth.sessions) ──
|
||||
// Both change minute-to-minute — a whole different cadence from
|
||||
// the account counts above. Rendered as a separate section on
|
||||
// the dashboard with the presence-dot visual cue.
|
||||
/** Distinct users behind non-revoked sessions active in the last
|
||||
* 5 min. Same 5-min window as the `oxicloud_sessions_online_users`
|
||||
* Prometheus gauge; single source of truth on the backend. */
|
||||
online_users: number;
|
||||
/** Non-revoked sessions active in the last 5 min. Ratio
|
||||
* `online_sessions / online_users` is the multi-device factor
|
||||
* (browser + desktop + phone). */
|
||||
online_sessions: number;
|
||||
server_version: string;
|
||||
drive_usage: DriveKindUsage[];
|
||||
auth_enabled: boolean;
|
||||
|
||||
@@ -60,11 +60,21 @@ export function listJobs(): Promise<JobSummary[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/admin/jobs/{name}/trigger?force=X&deep=X` — dispatch a job
|
||||
* on-demand. `force` bypasses per-tenant idempotency checks (e.g.
|
||||
* `trash_cleanup` skipping when nothing is due). `deep` opts into slow
|
||||
* variants (currently only `storage_consistency`, propagated by
|
||||
* `consistency_batch` to every child).
|
||||
* `POST /api/admin/jobs/{name}/trigger?force=X&deep=X&repair=X` —
|
||||
* dispatch a job on-demand.
|
||||
*
|
||||
* - `force` bypasses per-tenant idempotency checks (e.g. `trash_cleanup`
|
||||
* skipping when nothing is due).
|
||||
* - `deep` opts into slow variants (currently only `storage_consistency`,
|
||||
* propagated by `consistency_batch` to every child).
|
||||
* - `repair` opts into corrective action on the refcount consistency
|
||||
* tenants (`blobs_consistency`, `manifests_consistency`, and
|
||||
* `consistency_batch` which fans out to both). Content-safe: only the
|
||||
* stored counter changes to match the auditor's computed value. Race-
|
||||
* safe: the corrective UPDATE recomputes the auditor formula in the
|
||||
* same statement, so a concurrent write can't leave a stale value.
|
||||
* Default `false` preserves discovery-only behaviour — surface a
|
||||
* confirm-first flow when calling with `repair: true`.
|
||||
*
|
||||
* Throws on 4xx / 5xx with the backend's error message when present.
|
||||
* A 404 means the job name isn't registered — surface that specifically
|
||||
@@ -72,11 +82,12 @@ export function listJobs(): Promise<JobSummary[]> {
|
||||
*/
|
||||
export async function triggerJob(
|
||||
name: string,
|
||||
opts: { force?: boolean; deep?: boolean; storage?: string } = {}
|
||||
opts: { force?: boolean; deep?: boolean; storage?: string; repair?: boolean } = {}
|
||||
): Promise<TriggerResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.force) params.set('force', 'true');
|
||||
if (opts.deep) params.set('deep', 'true');
|
||||
if (opts.repair) params.set('repair', 'true');
|
||||
// `storage` scopes tenants that respect JobRunArgs.storage —
|
||||
// currently blobs_consistency / backend_consistency (probes the
|
||||
// named entry instead of the live backend). See
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { ApiError, apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { AuthResponse, User } from '$lib/api/types';
|
||||
import type { AuthResponse, SelfUser } from '$lib/api/types';
|
||||
|
||||
/**
|
||||
* Best-effort parse of the backend `ErrorResponse` shape
|
||||
@@ -45,7 +45,7 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
* Failure to build a proof (no keypair, missing WebCrypto) falls back to a
|
||||
* headerless request — the server still accepts it for unbound sessions.
|
||||
*/
|
||||
export async function fetchMe(): Promise<User | null> {
|
||||
export async function fetchMe(): Promise<SelfUser | null> {
|
||||
// Build + sign a DPoP proof, send with the header, harvest any
|
||||
// `DPoP-Nonce` off the response into the shared client cache
|
||||
// (so the NEXT apiFetch call reuses it — no wasted round trip).
|
||||
@@ -80,7 +80,7 @@ export async function fetchMe(): Promise<User | null> {
|
||||
if (dpopMod && dpopMod.isDpopNonceChallenge(res)) res = await send();
|
||||
if (res.status === 401) return null;
|
||||
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
|
||||
return (await res.json()) as User;
|
||||
return (await res.json()) as SelfUser;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,7 +408,7 @@ export async function setupAdmin(email: string, password: string): Promise<void>
|
||||
* failure, not an expired access token. Returns the user on success, null on
|
||||
* any failure so the caller can fall through to the normal login UI.
|
||||
*/
|
||||
export async function exchangeOidcCode(code: string): Promise<User | null> {
|
||||
export async function exchangeOidcCode(code: string): Promise<SelfUser | null> {
|
||||
try {
|
||||
const res = await fetch('/api/auth/oidc/exchange', {
|
||||
method: 'POST',
|
||||
@@ -417,7 +417,7 @@ export async function exchangeOidcCode(code: string): Promise<User | null> {
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { user?: User };
|
||||
const data = (await res.json()) as { user?: SelfUser };
|
||||
return data.user ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -463,7 +463,7 @@ export async function register(email: string, password?: string, username?: stri
|
||||
* authenticated; a 401 here IS a genuine "session expired" and the
|
||||
* refresh interceptor is the right response.
|
||||
*/
|
||||
export async function upgradeToInternal(password?: string): Promise<User> {
|
||||
export async function upgradeToInternal(password?: string): Promise<SelfUser> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (password) body.password = password;
|
||||
const res = await apiFetch('/api/auth/upgrade-to-internal', {
|
||||
@@ -482,7 +482,7 @@ export async function upgradeToInternal(password?: string): Promise<User> {
|
||||
message
|
||||
);
|
||||
}
|
||||
return (await res.json()) as User;
|
||||
return (await res.json()) as SelfUser;
|
||||
}
|
||||
|
||||
export type MagicLinkResult = 'sent' | 'unavailable';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Profile / account endpoints — ported from views/profile/profile.js. */
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { User } from '$lib/api/types';
|
||||
import type { SelfUser } from '$lib/api/types';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
@@ -24,7 +24,7 @@ export interface ProfilePatch {
|
||||
ui_preferences?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function updateProfile(patch: ProfilePatch): Promise<User> {
|
||||
export async function updateProfile(patch: ProfilePatch): Promise<SelfUser> {
|
||||
const res = await apiFetch('/api/auth/me/profile', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
@@ -57,7 +57,7 @@ export async function updateProfile(patch: ProfilePatch): Promise<User> {
|
||||
}
|
||||
throw new Error(err.message || err.error || `profile update failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as User;
|
||||
return (await res.json()) as SelfUser;
|
||||
}
|
||||
|
||||
export async function changePassword(currentPw: string, newPw: string): Promise<void> {
|
||||
|
||||
@@ -16,15 +16,23 @@ export interface ResolvedUser {
|
||||
email: string;
|
||||
image: string | null;
|
||||
isExternal: boolean;
|
||||
/** Presence — TRUE when the server observed a request on any of this
|
||||
* user's non-revoked sessions within the last 5 min (backend
|
||||
* `PublicUserDto.is_online`). Drives the presence dot overlay on
|
||||
* `<UserAvatar>` / `<UserVignette>`. `false` when the caller's
|
||||
* source didn't compute presence (a bare `resolveUser(id)` from
|
||||
* pre-3-layer callers, an older backend build) — dot stays dark. */
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
/** Subset of the backend `UserDto` we consume here. */
|
||||
interface UserDtoShape {
|
||||
/** Subset of the backend `PublicUserDto` we consume here. */
|
||||
interface PublicUserShape {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
is_external: boolean;
|
||||
is_online?: boolean;
|
||||
}
|
||||
|
||||
// id → in-flight/resolved lookup (the Promise is cached so concurrent callers
|
||||
@@ -41,13 +49,14 @@ export function resolveUser(id: string): Promise<ResolvedUser | null> {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const u = (await res.json()) as UserDtoShape;
|
||||
const u = (await res.json()) as PublicUserShape;
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.username?.trim() || u.email || u.id,
|
||||
email: u.email ?? '',
|
||||
image: u.image ?? null,
|
||||
isExternal: u.is_external
|
||||
isExternal: u.is_external,
|
||||
isOnline: u.is_online ?? false
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -57,3 +66,37 @@ export function resolveUser(id: string): Promise<ResolvedUser | null> {
|
||||
cache.set(id, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prime the resolver cache from data the caller already has in hand.
|
||||
* When a list endpoint (e.g. `/api/admin/users`) ships full
|
||||
* `PublicUser` rows, the admin page seeds this cache in its load path
|
||||
* so every subsequent `resolveUser(id)` call (from `UserVignette`
|
||||
* mounted per-row) hits the cache synchronously — no per-row
|
||||
* `/api/users/{id}` follow-up fetch. Kills the N+1 that motivated
|
||||
* widening `/api/admin/users` to include the avatar (see
|
||||
* `docs/plan/userdto-refactor.md` § N+1).
|
||||
*
|
||||
* No-op when the id is already cached (in-flight or resolved). This
|
||||
* makes seeding safe to call unconditionally — never clobbers an
|
||||
* authoritative in-flight lookup with a stale seed.
|
||||
*/
|
||||
export function seedUser(u: {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
is_external: boolean;
|
||||
is_online?: boolean;
|
||||
}): void {
|
||||
if (cache.has(u.id)) return;
|
||||
const resolved: ResolvedUser = {
|
||||
id: u.id,
|
||||
name: u.username?.trim() || u.email || u.id,
|
||||
email: u.email,
|
||||
image: u.image ?? null,
|
||||
isExternal: u.is_external,
|
||||
isOnline: u.is_online ?? false
|
||||
};
|
||||
cache.set(u.id, Promise.resolve(resolved));
|
||||
}
|
||||
|
||||
+146
-126
@@ -179,148 +179,116 @@ export interface TrashResourcesResponse {
|
||||
|
||||
export type Role = 'user' | 'admin';
|
||||
|
||||
/** Wire shape of `UserDto` (backend: src/application/dtos/user_dto.rs). */
|
||||
export interface User {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Three-layer user family — mirrors src/application/dtos/user_dto.rs.
|
||||
// See docs/plan/userdto-refactor.md.
|
||||
//
|
||||
// `PublicUser` — public identity. Every authenticated caller may see it.
|
||||
// Returned by /api/users/{id}, share responses, group
|
||||
// members, magic-link invitees, recipient enrichment.
|
||||
// `FullUser` — `{ user: PublicUser, ...admin+self extras }`. Returned
|
||||
// as Vec by /api/admin/users; embedded in `SelfUser`.
|
||||
// `SelfUser` — `{ full: FullUser, ...self-only extras }`. Returned by
|
||||
// /api/auth/me and by every auth response.
|
||||
//
|
||||
// Adding a field? Decide by audience:
|
||||
// * Any authenticated caller may see it about another user → PublicUser.
|
||||
// * Only admin (about another user) AND self (about self) → FullUser.
|
||||
// * Only self about themselves → SelfUser.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Public identity — 9 fields visible to any authenticated caller. */
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username?: string;
|
||||
email: string;
|
||||
role: string;
|
||||
storage_quota_bytes: number;
|
||||
storage_used_bytes: number;
|
||||
image?: string | null;
|
||||
is_external: boolean;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
/** Presence — TRUE when the server observed a request on any of this
|
||||
* user's non-revoked sessions within the last 5 min. Populated on
|
||||
* list endpoints; single-user public paths default to `false`.
|
||||
* Backwards-compat: missing on older backend builds → `false`. */
|
||||
is_online?: boolean;
|
||||
}
|
||||
|
||||
/** Full user record — public identity + all fields BOTH an admin (viewing
|
||||
* another user) AND the subject themselves may see. Returned as `Vec` by
|
||||
* `/api/admin/users`; embedded in `SelfUser` for `/api/auth/me`. */
|
||||
export interface FullUser {
|
||||
user: PublicUser;
|
||||
/** IdP linkage. Load-bearing "is federated?" predicate:
|
||||
* `full.federation_kind === 'oidc'`. */
|
||||
federation_kind?: 'oidc' | 'ocm' | 'magic_link';
|
||||
/** Authority that minted the OIDC/OCM identity — issuer URL for OIDC,
|
||||
* peer domain for OCM. FE that wants a friendly label maps this
|
||||
* against `OidcProviders.issuer → provider_name`. */
|
||||
federation_issuer?: string;
|
||||
preferred_locale?: string;
|
||||
email_verified_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login_at?: string | null;
|
||||
active: boolean;
|
||||
/**
|
||||
* Which trust chain minted this user's federation identity. `null`
|
||||
* (omitted from wire) for local users (password / OPAQUE only).
|
||||
* `"oidc" | "ocm" | "magic_link"` for federated users. Predicate:
|
||||
* `!user.federation_kind` = local; `user.federation_kind === 'oidc'`
|
||||
* = OIDC user. Mirrors `auth.users.federation_kind` verbatim.
|
||||
*/
|
||||
federation_kind?: 'oidc' | 'ocm' | 'magic_link';
|
||||
/**
|
||||
* Authority that minted this user's OIDC/OCM identity — issuer URL
|
||||
* for OIDC (id_token `iss`), peer domain for OCM. `null` (omitted)
|
||||
* for local users. FE that wants a friendly display label maps this
|
||||
* against `OidcProviders.issuer → provider_name` when they match;
|
||||
* shows the raw value otherwise. Renamed from the historical
|
||||
* `auth_provider` (which held a display label pre-Phase-B and a
|
||||
* `"local"` sentinel for non-federated users — both are gone).
|
||||
*/
|
||||
federation_issuer?: string;
|
||||
image?: string | null;
|
||||
can_edit_image: boolean;
|
||||
is_external: boolean;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
email_verified_at?: string;
|
||||
preferred_locale?: string;
|
||||
notify_on_share: boolean;
|
||||
/**
|
||||
* Opaque UI preferences bag. Server-side JSONB column that persists
|
||||
* pure UI toggles (hide-dotfiles, view mode, sidebar collapse, …)
|
||||
* across devices. The server never inspects the contents — the SPA
|
||||
* defines the keys (see `lib/stores/preferences.svelte.ts` for the
|
||||
* typed view). Always an object on the wire (empty bag is `{}`,
|
||||
* never `null` or missing).
|
||||
*
|
||||
* When PATCHing back to the server via
|
||||
* `PATCH /api/auth/me/profile { ui_preferences: {...} }`, the
|
||||
* server SHALLOW-merges — only the keys present in the patch are
|
||||
* touched, so partial writes from one device don't clobber
|
||||
* preferences set on another. Set a key to `null` in the patch to
|
||||
* delete it from the bag.
|
||||
*/
|
||||
ui_preferences: Record<string, unknown>;
|
||||
/**
|
||||
* Mirrors `auth.users.force_password_change_at_next_login`. Only
|
||||
* populated by `GET /api/auth/me` (see the backend UserDto doc for
|
||||
* why other UserDto call-sites default to false). When true, the
|
||||
* SPA MUST lock navigation to the password-change surface — the
|
||||
* root layout's guard + the backend's `require_no_password_change_pending`
|
||||
* middleware together enforce this. Optional on the wire because
|
||||
* older backend builds omit it and `#[serde(default)]` maps
|
||||
* missing → `false`.
|
||||
*/
|
||||
force_password_change?: boolean;
|
||||
/**
|
||||
* TRUE when the account has a local Argon2id `password_hash` on
|
||||
* file. Distinct from `federation_kind`: an OIDC-linked account
|
||||
* (`federation_kind === 'oidc'`) can ALSO carry a local password
|
||||
* (hybrid posture — SSO for daily login, local password as
|
||||
* fallback). The profile page's change-password card gates on this
|
||||
* flag rather than on the federation shape so hybrid users can
|
||||
* rotate their local credential. Optional on the wire for older-
|
||||
* backend compatibility; missing → `false` (safe default: hide the
|
||||
* card).
|
||||
*/
|
||||
has_password?: boolean;
|
||||
/**
|
||||
* TRUE when the caller's current session is DPoP-bound (row's
|
||||
* `dpop_jkt IS NOT NULL`). Populated only by `/api/auth/me`; other
|
||||
* User-emitting endpoints leave it unset.
|
||||
*
|
||||
* The session store reads this to skip a redundant
|
||||
* `POST /api/auth/dpop/bind` call — the endpoint returns 409
|
||||
* `already_bound` on repeated attempts (anti-downgrade invariant)
|
||||
* and each rejection logs at audit INFO, so a naive "bind on
|
||||
* every load" pattern was cluttering the audit stream. We only
|
||||
* fire bind now when there's actual work to do (fresh OIDC /
|
||||
* magic-link session that landed unbound).
|
||||
*/
|
||||
is_dpop_bound?: boolean;
|
||||
storage_quota_bytes: number;
|
||||
storage_used_bytes: number;
|
||||
/** TRUE when the account has a local Argon2id `password_hash` on file.
|
||||
* Distinct from `federation_kind`: an OIDC-linked account can ALSO
|
||||
* carry a local password (hybrid). */
|
||||
has_password: boolean;
|
||||
/** TRUE when the user has an OPAQUE envelope on file. Admin-visible
|
||||
* rollout signal — kept off `PublicUser` so directory endpoints don't
|
||||
* leak OPAQUE adoption. */
|
||||
opaque_registered: boolean;
|
||||
/** TRUE when the user has completed ≥1 OPAQUE login. Distinct from
|
||||
* `opaque_registered` — envelope-on-file vs successful-login. */
|
||||
opaque_migrated: boolean;
|
||||
}
|
||||
|
||||
/** Fields rendered by the paginated admin table. Full account details remain
|
||||
* available from the detail endpoint; this shape keeps avatars and preference
|
||||
* documents off every listing page.
|
||||
*
|
||||
* The two OPAQUE flags below are ADMIN-ONLY signals: they surface per-user
|
||||
* OPAQUE rollout progress in the admin table. The backend deliberately keeps
|
||||
* them off `UserDto` (`/api/auth/me`, share-recipient DTOs, group members)
|
||||
* so a non-admin can't enumerate the adoption set through third-party
|
||||
* endpoints. Both optional on the wire — older backend builds omit them and
|
||||
* `#[serde(default)]` maps missing → `false`. */
|
||||
export type AdminUserSummary = Pick<
|
||||
User,
|
||||
| 'id'
|
||||
| 'username'
|
||||
| 'email'
|
||||
| 'role'
|
||||
| 'storage_quota_bytes'
|
||||
| 'storage_used_bytes'
|
||||
| 'last_login_at'
|
||||
| 'active'
|
||||
| 'federation_kind'
|
||||
| 'federation_issuer'
|
||||
| 'is_external'
|
||||
> & {
|
||||
/** TRUE = user has a server-verifiable password on file (legacy or
|
||||
* admin-set). Combined with `opaque_registered` and `federation_kind`,
|
||||
* the admin table derives the full auth capability set — a user with
|
||||
* `has_password=false`, `opaque_registered=false` AND
|
||||
* `federation_kind === undefined` (no federation) is passwordless
|
||||
* (magic-link only, which is the default for externals). */
|
||||
has_password?: boolean;
|
||||
/** TRUE = user has an OPAQUE envelope on file (Phase 2 silent migration
|
||||
* succeeded, or the user completed a manual re-registration). */
|
||||
opaque_registered?: boolean;
|
||||
/** TRUE = user has completed at least one successful OPAQUE login.
|
||||
* Distinct from `opaque_registered` — the envelope may have been
|
||||
* cleared by an admin reset while a stale migrated=true remains as
|
||||
* historical signal (backend clears both atomically today, but the
|
||||
* two-flag shape keeps the option open for a future policy split). */
|
||||
opaque_migrated?: boolean;
|
||||
};
|
||||
/** Self view — everything the caller may see about themselves.
|
||||
* Returned by `/api/auth/me` and every `AuthResponse` (login / refresh /
|
||||
* OIDC callback / magic-link redemption ships this so the SPA's post-auth
|
||||
* state matches its post-`/me` state with no UI race). */
|
||||
export interface SelfUser {
|
||||
full: FullUser;
|
||||
/** Opaque UI-preferences bag. Cross-device store for pure UI toggles
|
||||
* (view mode, sidebar collapse, hide-dotfiles, …). Server never
|
||||
* inspects contents; the SPA defines the keys (see
|
||||
* `lib/stores/preferences.svelte.ts`). Always an object on the wire
|
||||
* — empty bag is `{}`, never `null`. PATCH via `/api/auth/me/profile`
|
||||
* shallow-merges; setting a key to `null` removes it. */
|
||||
ui_preferences: Record<string, unknown>;
|
||||
/** Whether the user wants share-notification emails. */
|
||||
notify_on_share: boolean;
|
||||
/** Session-scoped: my current session is DPoP-bound. SPA reads this
|
||||
* on `session.load()` to skip a redundant `/api/auth/dpop/bind` call
|
||||
* (409 `already_bound` otherwise, noisy in the audit stream). */
|
||||
is_dpop_bound: boolean;
|
||||
/** Admin-set temp-password gate — SPA nav guard blocks everything
|
||||
* but /change-password until this flips back. Cleared by a successful
|
||||
* `POST /api/auth/change-password`. */
|
||||
force_password_change: boolean;
|
||||
/** Caller-scoped: can I edit my own avatar? `false` for OIDC users
|
||||
* whose avatar comes from the IdP. Only meaningful when caller ==
|
||||
* subject; nonsense on any other DTO. */
|
||||
can_edit_image: boolean;
|
||||
}
|
||||
|
||||
/** Backwards-compat alias while migrating call-sites. Prefer `PublicUser`
|
||||
* for public-identity contexts (sharee, group member, invitee) or
|
||||
* `SelfUser` when reading `/api/auth/me`. Delete once no consumers reference
|
||||
* the bare `User` name. */
|
||||
export type User = PublicUser;
|
||||
|
||||
export interface AdminUsersPage {
|
||||
total: number;
|
||||
users: AdminUserSummary[];
|
||||
users: FullUser[];
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
user: SelfUser;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
@@ -650,8 +618,32 @@ export interface PausedRunBrief {
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a job changes state — `RecoverableJobHandler::mutates()` on the
|
||||
* backend. Three values rather than a boolean because the interesting
|
||||
* case is conditional: a job can be read-only by default and destructive
|
||||
* under `?repair=true`.
|
||||
*
|
||||
* - `never` — read-only under every flag. Render a read-only badge; no
|
||||
* confirmation needed to trigger.
|
||||
* - `always` — changes state on a plain run. Confirm before triggering.
|
||||
* - `on_repair_only` — safe to trigger; confirm only when the repair
|
||||
* toggle is on.
|
||||
*/
|
||||
export type Mutates = 'never' | 'always' | 'on_repair_only';
|
||||
|
||||
export interface JobSummary {
|
||||
name: string;
|
||||
/** One or two sentences on what the job does, in English, authored
|
||||
* next to the handler. Absent for jobs that haven't declared one —
|
||||
* omit the line rather than rendering an empty block. */
|
||||
description?: string;
|
||||
mutates: Mutates;
|
||||
/** Present iff `?repair=true` does something beyond a default run;
|
||||
* describes what it ADDS. Presence is what gates the repair toggle;
|
||||
* the text is the confirmation copy. Independent of `mutates` — the
|
||||
* thumbnail import jobs are `always` AND repair-capable. */
|
||||
repair_description?: string;
|
||||
interval_ms?: number;
|
||||
next_run_at?: string;
|
||||
last_run_at?: string;
|
||||
@@ -671,6 +663,19 @@ export interface JobSummary {
|
||||
* for this job. Distinct from `running` — a paused run is
|
||||
* resumable via the same trigger endpoint. */
|
||||
paused_run?: PausedRunBrief;
|
||||
/** Present iff `OXICLOUD_STARTUP_JOBS` names this job — the flags it
|
||||
* is dispatched with at every boot. Worth showing: a job configured
|
||||
* with `repair: true` deletes on every restart, and the row would
|
||||
* otherwise suggest that only happens when someone clicks Run. */
|
||||
startup?: StartupTrigger;
|
||||
}
|
||||
|
||||
/** Flags a job configured in `OXICLOUD_STARTUP_JOBS` runs with. */
|
||||
export interface StartupTrigger {
|
||||
force: boolean;
|
||||
deep: boolean;
|
||||
repair: boolean;
|
||||
storage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -761,12 +766,27 @@ export interface SessionSummary {
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
/** Wall-clock (RFC 3339) of the last authenticated request the
|
||||
* server observed on this session. Trails the true value by at
|
||||
* most the tracker's flush interval (30 s) on a running server;
|
||||
* converges after graceful shutdown. Populates the "last seen X
|
||||
* ago" tooltip on the presence dot. */
|
||||
last_seen_at: string;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
is_bound: boolean;
|
||||
dpop_jkt_prefix: string | null;
|
||||
is_revoked: boolean;
|
||||
is_active: boolean;
|
||||
/** Presence signal — `true` when the server observed a request on
|
||||
* this session within the last 5 minutes AND the row is `is_active`
|
||||
* (never `true` on revoked / expired rows). Renders as a filled
|
||||
* green dot in the Status column; `false` on an otherwise-active
|
||||
* row renders as an outlined idle dot with a "last seen X ago"
|
||||
* tooltip. Distinct from `is_active`: that's a lifecycle signal,
|
||||
* this is a presence signal. Derived server-side against the
|
||||
* same window that drives `oxicloud_sessions_online[_users]`. */
|
||||
is_online: boolean;
|
||||
/** How this session was minted. `unknown` covers pre-migration
|
||||
* rows and any origin the SPA doesn't yet render. Server enum
|
||||
* is populated at INSERT (see `Session::new`) and copied on
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { errorMessage } from '$lib/utils/errors';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
@@ -66,6 +67,43 @@
|
||||
else busyKeys.delete(key);
|
||||
}
|
||||
|
||||
// Per-job "Run" split-button menu state. Keyed by job name so
|
||||
// two rows can open their menus independently (though the
|
||||
// outside-click handler below closes all on any click outside
|
||||
// any menu — matching the /files upload dropdown pattern). Only
|
||||
// rows with `supportsDeep` OR `supportsRepair` render a chevron;
|
||||
// the plain-Run rows (drives/folders/files/backend/… consistency,
|
||||
// trash_cleanup, dedup_gc, …) show a bare "Run" button with no
|
||||
// menu, keeping the common case one-click.
|
||||
let runMenuOpen = $state<Record<string, boolean>>({});
|
||||
function toggleRunMenu(name: string) {
|
||||
runMenuOpen = { ...runMenuOpen, [name]: !runMenuOpen[name] };
|
||||
}
|
||||
function closeAllRunMenus() {
|
||||
runMenuOpen = {};
|
||||
}
|
||||
// Global outside-click + Escape dismiss. Only registered while at
|
||||
// least one menu is open — a background admin tab doesn't hold
|
||||
// listeners.
|
||||
$effect(() => {
|
||||
const anyOpen = Object.values(runMenuOpen).some((v) => v);
|
||||
if (!anyOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.jobs-panel__split')) {
|
||||
closeAllRunMenus();
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeAllRunMenus();
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', onDown);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
});
|
||||
|
||||
// Purge-modal state. Null = closed; otherwise carries the
|
||||
// draft retention days the operator's picking. Kept separate
|
||||
// from the top-bar action state so mouse-away doesn't lose
|
||||
@@ -129,7 +167,7 @@
|
||||
.slice()
|
||||
// `consistency_batch` is served by the top-bar
|
||||
// action buttons; hiding it here removes the
|
||||
// duplicate table row. `hasBatch` still checks the
|
||||
// duplicate table row. `batchJob` still reads from the
|
||||
// full fetched list so the top buttons only render
|
||||
// when the coordinator is actually registered.
|
||||
.filter((j) => j.name !== 'consistency_batch')
|
||||
@@ -142,7 +180,7 @@
|
||||
// Track whether the coordinator is registered so the
|
||||
// top-bar buttons can gate on it without checking `jobs`
|
||||
// (which now filters it out).
|
||||
hasBatch = fetched.some((j) => j.name === 'consistency_batch');
|
||||
batchJob = fetched.find((j) => j.name === 'consistency_batch') ?? null;
|
||||
loadError = null;
|
||||
} catch (e) {
|
||||
loadError = errorMessage(e);
|
||||
@@ -212,13 +250,15 @@
|
||||
|
||||
// ─── Expansion toggles ─────────────────────────────────────────────
|
||||
|
||||
function toggleJob(name: string) {
|
||||
if (expandedJob === name) {
|
||||
function toggleJob(job: JobSummary) {
|
||||
if (expandedJob === job.name) {
|
||||
expandedJob = null;
|
||||
} else {
|
||||
expandedJob = name;
|
||||
// Lazy-load on first open, refresh on subsequent opens.
|
||||
void loadRuns(name);
|
||||
expandedJob = job.name;
|
||||
// Lazy-load on first open, refresh on subsequent opens. Only
|
||||
// recoverable jobs have runs to load — the others expand purely
|
||||
// to show their description.
|
||||
if (isRecoverable(job)) void loadRuns(job.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,8 +274,10 @@
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────
|
||||
|
||||
async function onTrigger(name: string, opts: { deep?: boolean } = {}) {
|
||||
const key = `trigger:${name}${opts.deep ? ':deep' : ''}`;
|
||||
async function onTrigger(name: string, opts: { deep?: boolean; repair?: boolean } = {}) {
|
||||
// Key suffix has to keep every dispatched variant distinct so the
|
||||
// button-disabled state of one doesn't lock out another mid-flight.
|
||||
const key = `trigger:${name}${opts.deep ? ':deep' : ''}${opts.repair ? ':repair' : ''}`;
|
||||
markBusy(key, true);
|
||||
try {
|
||||
// Fire the trigger + a follow-up loadJobs after a short delay
|
||||
@@ -265,10 +307,49 @@
|
||||
if (!res.outcome) {
|
||||
// dispatched (detached) — no outcome to render
|
||||
} else if (res.outcome.outcome === 'ok') {
|
||||
ui.notify(
|
||||
t('admin.jobs.triggered_ok', { name }, '{{name}} triggered successfully'),
|
||||
'success'
|
||||
);
|
||||
// Repair runs surface a rollup so the operator sees
|
||||
// whether corrective UPDATEs actually fired. `extra`
|
||||
// carries `repaired_count` on the two refcount tenants
|
||||
// directly, and nested under `per_check[*].extra` when
|
||||
// dispatched via `consistency_batch`. Sum across the
|
||||
// per_check dict if present, else read the top-level.
|
||||
let repairedTotal = 0;
|
||||
let sawRepair = false;
|
||||
const extra = (res.outcome.extra ?? {}) as {
|
||||
repair_requested?: boolean;
|
||||
repaired_count?: number;
|
||||
per_check?: Record<
|
||||
string,
|
||||
{ extra?: { repair_requested?: boolean; repaired_count?: number } }
|
||||
>;
|
||||
};
|
||||
if (extra.repair_requested) {
|
||||
sawRepair = true;
|
||||
repairedTotal += extra.repaired_count ?? 0;
|
||||
}
|
||||
if (extra.per_check) {
|
||||
for (const child of Object.values(extra.per_check)) {
|
||||
if (child?.extra?.repair_requested) {
|
||||
sawRepair = true;
|
||||
repairedTotal += child.extra.repaired_count ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sawRepair) {
|
||||
ui.notify(
|
||||
t(
|
||||
'admin.jobs.triggered_ok_repair',
|
||||
{ name, n: repairedTotal },
|
||||
'{{name}}: {{n}} counter(s) repaired'
|
||||
),
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
ui.notify(
|
||||
t('admin.jobs.triggered_ok', { name }, '{{name}} triggered successfully'),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ui.notify(
|
||||
t(
|
||||
@@ -379,8 +460,9 @@
|
||||
* Per-severity finding counts from `last_outcome.extra.severity_counts`
|
||||
* (a JSON object populated by `run_or_resume`). Missing / older
|
||||
* runs return an empty record — callers should tolerate absent keys.
|
||||
* The three severity values are the ones consistency tenants emit
|
||||
* today: `data_loss`, `inconsistent`, `anomaly`.
|
||||
* Severity values emitted today: `data_loss`, `inconsistent`,
|
||||
* `anomaly`. The set is open (the column is TEXT), so unknown keys
|
||||
* must degrade rather than throw.
|
||||
*/
|
||||
function lastSeverityCounts(job: JobSummary): Record<string, number> {
|
||||
if (!job.last_outcome || job.last_outcome.outcome !== 'ok') return {};
|
||||
@@ -401,6 +483,13 @@
|
||||
return (s.data_loss ?? 0) + (s.inconsistent ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Informational findings. `anomaly` is the wire value; "notice" is
|
||||
* what the panel calls it — there is no separate `notice` severity.
|
||||
* A job that acted on what it found (a repair run deleting an
|
||||
* orphaned sidecar) records the same severity and says so in the
|
||||
* finding's `detail`.
|
||||
*/
|
||||
function anomalyFindingCount(job: JobSummary): number {
|
||||
return lastSeverityCounts(job).anomaly ?? 0;
|
||||
}
|
||||
@@ -548,13 +637,75 @@
|
||||
// Jobs that respect `?deep=true`:
|
||||
// * `consistency_batch` — propagates deep to every child that
|
||||
// understands it
|
||||
// * `blobs_consistency` — deep mode re-reads + re-hashes every
|
||||
// blob for silent bit-rot detection (severity `data_loss`).
|
||||
// Full read of storage; can take hours on big installs — the
|
||||
// "Run" (normal) button on the same row does the cheap
|
||||
// existence probes only.
|
||||
// * `backend_consistency` — deep mode re-reads + re-hashes every
|
||||
// matched blob for silent bit-rot detection (severity
|
||||
// `data_loss`). Full read of storage; can take hours on big
|
||||
// installs — the "Run" button on the same row does the
|
||||
// enumeration merge-join only. This was `blobs_consistency`
|
||||
// until that tenant became database-only.
|
||||
function supportsDeep(name: string): boolean {
|
||||
return name === 'consistency_batch' || name === 'blobs_consistency';
|
||||
return name === 'consistency_batch' || name === 'backend_consistency';
|
||||
}
|
||||
|
||||
// Whether `?repair=true` does anything for this job — declared by the
|
||||
// handler itself via `repair_description()`, not by a name allowlist
|
||||
// here. The allowlist this replaces named only the two ref_count
|
||||
// tenants and silently omitted every repair-capable job added since,
|
||||
// so the thumbnail imports could not be run in repair mode from the
|
||||
// panel at all despite supporting it.
|
||||
function supportsRepair(job: JobSummary): boolean {
|
||||
return !!job.repair_description;
|
||||
}
|
||||
|
||||
// What the repair adds, in the handler's own words. The backend owns
|
||||
// this string precisely because the wording differs per job: correcting
|
||||
// a counter and unlinking files off disk are not the same warning, and
|
||||
// the frontend has no way to tell them apart.
|
||||
async function onTriggerWithRepairConfirm(job: JobSummary) {
|
||||
const ok = await confirmDialog({
|
||||
title: t(
|
||||
'admin.jobs.run_repair_confirm_title_scoped',
|
||||
{ name: job.name },
|
||||
'Run {{name}} in repair mode?'
|
||||
),
|
||||
message: job.repair_description ?? '',
|
||||
confirmText: t('admin.jobs.run_repair_confirm', 'Repair'),
|
||||
danger: true
|
||||
});
|
||||
if (ok) await onTrigger(job.name, { repair: true });
|
||||
}
|
||||
|
||||
// Confirmation before a plain run of a job that writes. `never` jobs
|
||||
// trigger straight through — that is the point of the flag — and
|
||||
// `on_repair_only` jobs are read-only until the repair variant is
|
||||
// picked, which carries its own confirm.
|
||||
async function onTriggerGuarded(job: JobSummary) {
|
||||
if (job.mutates === 'always') {
|
||||
const ok = await confirmDialog({
|
||||
title: t('admin.jobs.run_mutating_confirm_title', { name: job.name }, 'Run {{name}}?'),
|
||||
message:
|
||||
job.description ||
|
||||
t('admin.jobs.run_mutating_confirm_body', 'This job changes stored state when it runs.'),
|
||||
confirmText: t('admin.jobs.run', 'Run'),
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
await onTrigger(job.name);
|
||||
}
|
||||
|
||||
// Row badge. `never` is the one worth stating outright — it is the
|
||||
// answer to "is it safe to click this on production?", and it is the
|
||||
// question an operator asks before every trigger.
|
||||
function mutatesLabel(job: JobSummary): string | null {
|
||||
switch (job.mutates) {
|
||||
case 'never':
|
||||
return t('admin.jobs.mutates_never', 'read-only');
|
||||
case 'on_repair_only':
|
||||
return t('admin.jobs.mutates_on_repair_only', 'read-only unless repaired');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRunning(job: JobSummary): boolean {
|
||||
@@ -575,11 +726,13 @@
|
||||
// coordinator is registered (should always be true post-Slice 5,
|
||||
// but check defensively so the button doesn't appear on an old
|
||||
// deployment before this component is upgraded).
|
||||
// Coordinator registration flag — set imperatively in
|
||||
// `loadJobs` because `jobs` no longer contains the
|
||||
// `consistency_batch` row (filtered out to avoid duplicating the
|
||||
// top-bar action buttons).
|
||||
let hasBatch = $state(false);
|
||||
// Held as the whole summary rather than a boolean because the
|
||||
// top-bar buttons need its `repair_description` — the coordinator
|
||||
// describes its own repair semantics, same as every table row.
|
||||
// Set imperatively in `loadJobs` because `jobs` no longer contains
|
||||
// the `consistency_batch` row (filtered out to avoid duplicating
|
||||
// the top-bar action buttons).
|
||||
let batchJob = $state<JobSummary | null>(null);
|
||||
</script>
|
||||
|
||||
<section class="jobs-panel">
|
||||
@@ -593,10 +746,12 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="jobs-panel__header-actions">
|
||||
{#if hasBatch}
|
||||
{#if batchJob}
|
||||
{@const batch = batchJob}
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--primary"
|
||||
disabled={busyKeys.has('trigger:consistency_batch')}
|
||||
title={batch.description || undefined}
|
||||
onclick={() => onTrigger('consistency_batch')}
|
||||
>
|
||||
<Icon name="play" />
|
||||
@@ -614,10 +769,28 @@
|
||||
<Icon name="play" />
|
||||
{t('admin.jobs.run_deep', 'Run deep')}
|
||||
</button>
|
||||
<!-- Repair goes behind a confirm because it fans `?repair=true`
|
||||
out to every sub-check that acts on it. The confirmation
|
||||
text comes from the coordinator's own
|
||||
`repair_description` rather than being written here —
|
||||
what repair means changes as tenants gain repair arms,
|
||||
and this button would otherwise keep describing only the
|
||||
two refcount ones. -->
|
||||
{#if batch.repair_description}
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--warn"
|
||||
disabled={busyKeys.has('trigger:consistency_batch:repair')}
|
||||
title={batch.repair_description}
|
||||
onclick={() => onTriggerWithRepairConfirm(batch)}
|
||||
>
|
||||
<Icon name="cog" />
|
||||
{t('admin.jobs.run_repair', 'Repair ref_counts')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Purge is orthogonal to consistency — it works even
|
||||
when the batch coordinator isn't registered, so it
|
||||
lives outside the {#if hasBatch}. Opens a modal so
|
||||
lives outside the batch block. Opens a modal so
|
||||
the operator picks a retention window with intent
|
||||
(no accidental delete-all). -->
|
||||
<button
|
||||
@@ -661,7 +834,12 @@
|
||||
{@const runsErr = runsErrorByJob[job.name]}
|
||||
{@const runsLoading = runsLoadingByJob[job.name]}
|
||||
{@const expandedRun = expandedRunByJob[job.name] ?? null}
|
||||
{@const canExpand = isRecoverable(job)}
|
||||
<!-- Expandable if there is anything to show: a run history,
|
||||
a description, or both. Gating on `recoverable` alone
|
||||
would leave the plain periodic jobs (dedup_gc,
|
||||
trash_cleanup, …) with no way to reach their
|
||||
description at all. -->
|
||||
{@const canExpand = isRecoverable(job) || !!job.description}
|
||||
<tr class="jobs-panel__row" class:jobs-panel__row--expanded={expandedJob === job.name}>
|
||||
<td>
|
||||
{#if canExpand}
|
||||
@@ -669,7 +847,7 @@
|
||||
type="button"
|
||||
class="jobs-panel__expand"
|
||||
aria-expanded={expandedJob === job.name}
|
||||
onclick={() => toggleJob(job.name)}
|
||||
onclick={() => toggleJob(job)}
|
||||
>
|
||||
<Icon name={expandedJob === job.name ? 'chevron-down' : 'chevron-right'} />
|
||||
<span class="jobs-panel__name">{job.name}</span>
|
||||
@@ -677,8 +855,43 @@
|
||||
{:else}
|
||||
<span class="jobs-panel__name jobs-panel__name--flat">{job.name}</span>
|
||||
{/if}
|
||||
{#if mutatesLabel(job)}
|
||||
<span class="jobs-panel__pill jobs-panel__pill--readonly">
|
||||
{mutatesLabel(job)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<!-- "At boot" belongs in the cadence column: it answers
|
||||
WHEN this job runs, which is the same question
|
||||
`interval_ms` answers. Beside the name it read as a
|
||||
property of the job rather than of its schedule, and
|
||||
these two facts have to be read together — a job with
|
||||
no interval that fires at boot is not on-demand, and
|
||||
the row said "on-demand" next to a badge saying
|
||||
otherwise. -->
|
||||
<td class="jobs-panel__muted">
|
||||
{cadenceLabel(job)}
|
||||
{#if job.startup}
|
||||
<span
|
||||
class="jobs-panel__pill"
|
||||
class:jobs-panel__pill--paused={job.startup.repair}
|
||||
class:jobs-panel__pill--neutral={!job.startup.repair}
|
||||
title={job.startup.repair
|
||||
? t(
|
||||
'admin.jobs.startup_repair_tooltip',
|
||||
'Configured in OXICLOUD_STARTUP_JOBS to run in repair mode at every boot.'
|
||||
)
|
||||
: t(
|
||||
'admin.jobs.startup_tooltip',
|
||||
'Configured in OXICLOUD_STARTUP_JOBS to run at every boot.'
|
||||
)}
|
||||
>
|
||||
{job.startup.repair
|
||||
? t('admin.jobs.startup_repair', 'at boot · repair')
|
||||
: t('admin.jobs.startup', 'at boot')}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="jobs-panel__muted">{cadenceLabel(job)}</td>
|
||||
<td class="jobs-panel__muted">{timeAgo(job.last_run_at)}</td>
|
||||
<td>
|
||||
<div class="jobs-panel__outcome-cell">
|
||||
@@ -754,24 +967,84 @@
|
||||
{t('admin.jobs.cancel', 'Cancel')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--small"
|
||||
disabled={busyKeys.has(`trigger:${job.name}`)}
|
||||
onclick={() => onTrigger(job.name)}
|
||||
>
|
||||
{t('admin.jobs.run', 'Run')}
|
||||
</button>
|
||||
{#if supportsDeep(job.name)}
|
||||
<!-- Split-button: primary "Run" fires the default
|
||||
trigger; the chevron opens a menu with the
|
||||
tenant-specific variants (Run deep / Repair).
|
||||
Rows without any variant render a bare Run
|
||||
button — no chevron, no menu, no extra
|
||||
width. Preserves one-click discovery for
|
||||
the common case. -->
|
||||
{@const hasRunVariants = supportsDeep(job.name) || supportsRepair(job)}
|
||||
<span class="jobs-panel__split">
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--small"
|
||||
disabled={busyKeys.has(`trigger:${job.name}:deep`)}
|
||||
onclick={() => onTrigger(job.name, { deep: true })}
|
||||
class:jobs-panel__split-main={hasRunVariants}
|
||||
disabled={busyKeys.has(`trigger:${job.name}`)}
|
||||
title={job.description || undefined}
|
||||
onclick={() => {
|
||||
closeAllRunMenus();
|
||||
void onTriggerGuarded(job);
|
||||
}}
|
||||
>
|
||||
{t('admin.jobs.run_deep', 'Run deep')}
|
||||
{t('admin.jobs.run', 'Run')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasRunVariants}
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--small jobs-panel__split-toggle"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={runMenuOpen[job.name] ?? false}
|
||||
aria-label={t('admin.jobs.run_variants_menu', 'Run variants menu')}
|
||||
onclick={() => toggleRunMenu(job.name)}
|
||||
>
|
||||
<Icon name="caret-down" />
|
||||
</button>
|
||||
{#if runMenuOpen[job.name]}
|
||||
<div class="jobs-panel__run-menu" role="menu">
|
||||
{#if supportsDeep(job.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="jobs-panel__run-menu-item"
|
||||
role="menuitem"
|
||||
disabled={busyKeys.has(`trigger:${job.name}:deep`)}
|
||||
title={t(
|
||||
'admin.jobs.run_deep_hint',
|
||||
'Also runs slow variants (blob re-hash, bitrot detection).'
|
||||
)}
|
||||
onclick={() => {
|
||||
closeAllRunMenus();
|
||||
void onTrigger(job.name, { deep: true });
|
||||
}}
|
||||
>
|
||||
<Icon name="search" />
|
||||
<span>{t('admin.jobs.run_deep', 'Run deep')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if supportsRepair(job)}
|
||||
<button
|
||||
type="button"
|
||||
class="jobs-panel__run-menu-item jobs-panel__run-menu-item--warn"
|
||||
role="menuitem"
|
||||
disabled={busyKeys.has(`trigger:${job.name}:repair`)}
|
||||
title={job.repair_description}
|
||||
onclick={() => {
|
||||
closeAllRunMenus();
|
||||
void onTriggerWithRepairConfirm(job);
|
||||
}}
|
||||
>
|
||||
<Icon name="cog" />
|
||||
<span>{t('admin.jobs.run_repair', 'Repair')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isRunning(job) && canExpand}
|
||||
<!-- `isRecoverable`, not `canExpand`: the latter now also
|
||||
covers rows that expand only to show a description,
|
||||
and those must not gain a Cancel button they never
|
||||
had. -->
|
||||
{#if isRunning(job) && isRecoverable(job)}
|
||||
{#if isRecoverable(job)}
|
||||
<!-- Recoverable running: [Pause] preserves cursor
|
||||
for later resume; [Cancel] abandons terminally
|
||||
@@ -811,7 +1084,20 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{#if expandedJob === job.name}
|
||||
<!-- First row of the expanded block, and only visible there:
|
||||
the description answers "what is this job" before the
|
||||
run history answers "what did it do", and keeping it
|
||||
folded keeps the collapsed table scannable — 17 rows of
|
||||
two-line prose is not a table any more. -->
|
||||
{#if expandedJob === job.name && job.description}
|
||||
<tr class="jobs-panel__desc-row">
|
||||
<td colspan="6">
|
||||
<p class="jobs-panel__description">{job.description}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
{#if expandedJob === job.name && isRecoverable(job)}
|
||||
<tr class="jobs-panel__runs">
|
||||
<td colspan="6">
|
||||
<div class="jobs-panel__runs-inner">
|
||||
@@ -1304,6 +1590,90 @@
|
||||
color: var(--color-danger-text-alt);
|
||||
}
|
||||
|
||||
/* Warn variant — used for actions that mutate data but are content-
|
||||
safe / reversible-in-outcome (e.g. Repair ref_counts). Signals
|
||||
"read the tooltip and the confirm before clicking" without the
|
||||
danger red reserved for destructive delete-style buttons. */
|
||||
.jobs-panel__btn--warn {
|
||||
border-color: var(--color-warning-border);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
/* Split-button — inline flex holding a primary "Run" (fires default
|
||||
action) and a chevron (opens the variants menu). `position:
|
||||
relative` anchors the menu below the toggle. Only rendered on
|
||||
rows whose job supports at least one variant; plain-Run rows
|
||||
sidestep this whole structure. */
|
||||
.jobs-panel__split {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Attached-button trick: main loses its right border-radius, toggle
|
||||
loses its left. Toggle also loses its left border so the two
|
||||
don't render a double-thick divider. */
|
||||
.jobs-panel__split-main {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.jobs-panel__split-toggle {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: none;
|
||||
padding-left: 0.35rem;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
/* The variants menu — dropdown below the toggle, right-aligned so
|
||||
it doesn't overflow the Actions column edge into the next row's
|
||||
badge cell. Shadow + surface bg mirror the /files upload
|
||||
dropdown (`upload-dropdown-menu`); using local CSS here rather
|
||||
than the ported class so the jobs-panel keeps its scoped styling. */
|
||||
.jobs-panel__run-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-width: 10rem;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.jobs-panel__run-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobs-panel__run-menu-item:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.jobs-panel__run-menu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Warn colour on the menu item mirrors the button variant so the
|
||||
Repair option carries the same "attention-worthy but not
|
||||
destructive" visual weight as its top-bar counterpart. */
|
||||
.jobs-panel__run-menu-item--warn {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.jobs-panel__pill {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
@@ -1342,6 +1712,34 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* "read-only" sits beside the job name and answers the question an
|
||||
operator asks before every trigger. Deliberately quiet — it marks
|
||||
the safe case, so it should not compete with outcome pills. */
|
||||
.jobs-panel__pill--readonly {
|
||||
margin-left: 0.4rem;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Opens the expanded block, so it carries the drawer's background and
|
||||
drops its own separator — the runs table below it is part of the
|
||||
same block, not a new entry. */
|
||||
.jobs-panel__desc-row td {
|
||||
padding-left: 2rem; /* clears the chevron, lines up with the name */
|
||||
border-bottom-color: transparent;
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.jobs-panel__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.jobs-panel__runs {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
@@ -437,11 +437,11 @@
|
||||
{ mode: 'dark', icon: 'moon', label: t('user_menu.theme.dark', 'Dark') }
|
||||
];
|
||||
|
||||
const storagePct = $derived(
|
||||
session.user && session.user.storage_quota_bytes > 0
|
||||
? Math.min(100, (session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100)
|
||||
: 0
|
||||
);
|
||||
const storagePct = $derived.by(() => {
|
||||
const full = session.me?.full;
|
||||
if (!full || full.storage_quota_bytes <= 0) return 0;
|
||||
return Math.min(100, (full.storage_used_bytes / full.storage_quota_bytes) * 100);
|
||||
});
|
||||
|
||||
const initials = $derived(userInitials(session.user?.username || session.user?.email));
|
||||
|
||||
@@ -655,12 +655,12 @@
|
||||
<div class="storage-fill" style:width="{storagePct}%"></div>
|
||||
</div>
|
||||
<div class="storage-info">
|
||||
{#if session.user.storage_quota_bytes > 0}
|
||||
{Math.round(storagePct)}% · {formatBytes(session.user.storage_used_bytes)} / {formatBytes(
|
||||
session.user.storage_quota_bytes
|
||||
{#if (session.me?.full.storage_quota_bytes ?? 0) > 0}
|
||||
{Math.round(storagePct)}% · {formatBytes(session.me?.full.storage_used_bytes ?? 0)} / {formatBytes(
|
||||
session.me?.full.storage_quota_bytes ?? 0
|
||||
)}
|
||||
{:else}
|
||||
{formatBytes(session.user.storage_used_bytes)}
|
||||
{formatBytes(session.me?.full.storage_used_bytes ?? 0)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,18 +903,18 @@
|
||||
<div class="user-menu-storage-fill" style:width="{storagePct}%"></div>
|
||||
</div>
|
||||
<div class="user-menu-storage-text">
|
||||
{#if session.user.storage_quota_bytes > 0}
|
||||
{#if (session.me?.full.storage_quota_bytes ?? 0) > 0}
|
||||
{t(
|
||||
'storage.used',
|
||||
{
|
||||
percentage: Math.round(storagePct),
|
||||
used: formatBytes(session.user.storage_used_bytes),
|
||||
total: formatBytes(session.user.storage_quota_bytes)
|
||||
used: formatBytes(session.me?.full.storage_used_bytes ?? 0),
|
||||
total: formatBytes(session.me?.full.storage_quota_bytes ?? 0)
|
||||
},
|
||||
'{{percentage}}% used ({{used}} / {{total}})'
|
||||
)}
|
||||
{:else}
|
||||
{formatBytes(session.user.storage_used_bytes)}
|
||||
{formatBytes(session.me?.full.storage_used_bytes ?? 0)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,16 +26,27 @@ const children = createRawSnippet(() => ({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/files');
|
||||
session.user = {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
storage_used_bytes: 10,
|
||||
storage_quota_bytes: 100,
|
||||
is_external: false
|
||||
// Post the three-layer UserDto refactor, `session.user` is a
|
||||
// derived accessor over `session.me.full.user`; only `session.me`
|
||||
// is settable. Fixture composes the nested shape — public identity
|
||||
// (username/email/name) on `.full.user`, admin+self extras
|
||||
// (storage_*, has_password) on `.full`, self-only bag (ui_prefs,
|
||||
// dpop_bound, force_password_change, can_edit_image) at the top.
|
||||
// See docs/plan/userdto-refactor.md.
|
||||
session.me = {
|
||||
full: {
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
is_external: false
|
||||
},
|
||||
storage_used_bytes: 10,
|
||||
storage_quota_bytes: 100
|
||||
}
|
||||
} as never;
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,19 @@
|
||||
let { icon, title, hint, error = false, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty-state" class:empty-state--error={error} role={error ? 'alert' : undefined}>
|
||||
<!-- `data-testid="empty-state"` is a stable Playwright hook: consumers
|
||||
(ResourceList, ShareList, TrashList, …) only render this component
|
||||
once the underlying load resolved with no items — so waiting on
|
||||
this testid = "the listing definitively finished loading and is
|
||||
empty". Used by `tests/e2e/spa/files.spec.ts` to gate a cold-
|
||||
navigation upload behind the folder-loaded state (see the guard
|
||||
in `routes/files/[...path]/+page.svelte::guardUploadFolderReady`). -->
|
||||
<div
|
||||
class="empty-state"
|
||||
class:empty-state--error={error}
|
||||
role={error ? 'alert' : undefined}
|
||||
data-testid="empty-state"
|
||||
>
|
||||
{#if icon}<Icon name={icon} class="empty-state__icon" />{/if}
|
||||
{#if title}<p class="empty-state__title">{title}</p>{/if}
|
||||
{#if hint}<p class="empty-state__hint">{hint}</p>{/if}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
const label = $derived(resolved?.name ?? fallbackLabel ?? userId);
|
||||
const email = $derived(resolved?.email || fallbackSublabel || '');
|
||||
const isExternal = $derived(resolved?.isExternal ?? false);
|
||||
const isOnline = $derived(resolved?.isOnline ?? false);
|
||||
const image = $derived(resolved?.image ?? null);
|
||||
const colorIndex = $derived(avatarColorIndex(userId));
|
||||
const initials = $derived(userInitials(label));
|
||||
@@ -50,6 +51,22 @@
|
||||
<Icon name="building-circle-xmark" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if isOnline}
|
||||
<!-- Presence dot — top-right corner so it doesn't collide with the
|
||||
external badge at bottom-right. Only rendered when true (absent
|
||||
= offline reads cleanly on an avatar; no grey placeholder). Uses
|
||||
`--color-success-alt` (same green as `.badge--active` in the
|
||||
admin sessions panel + `.presence-dot--online` in the sessions
|
||||
status column) so the presence signal reads consistently across
|
||||
every surface. The 2px surface-coloured border visually detaches
|
||||
the dot from the avatar's own background — matches the pattern
|
||||
Slack/Teams/Discord use. -->
|
||||
<span
|
||||
class="uv__presence"
|
||||
title={t('share.userOnline', 'Online')}
|
||||
aria-label={t('share.userOnline', 'Online')}
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="uv__text">
|
||||
<span class="uv__name">{label}</span>
|
||||
@@ -128,6 +145,27 @@
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Presence dot — top-right, symmetric with `.uv__badge` at
|
||||
bottom-right so the two corners don't collide. Slightly smaller
|
||||
(10x10 vs the badge's 16x16) because it's a pure signal — no
|
||||
icon, no text. The 2px `--color-bg-surface` border creates a
|
||||
visual gap between dot and avatar so the green pops out cleanly
|
||||
regardless of avatar palette (photo, dark initials, light
|
||||
initials). `box-sizing: border-box` keeps the inner circle's
|
||||
green footprint at 6x6 — same visual weight the sessions-table
|
||||
dot has. See `docs/plan/sessions.md` § UI. */
|
||||
.uv__presence {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: -2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success-alt);
|
||||
border: 2px solid var(--color-bg-surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.uv__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -69,12 +69,15 @@ const PATCH_DEBOUNCE_MS = 500;
|
||||
|
||||
class PreferencesStore {
|
||||
/**
|
||||
* The typed view of the bag. Derived from `session.user?.ui_preferences`
|
||||
* so signing in / out / refresh flips it in lockstep with the session.
|
||||
* The typed view of the bag. Derived from `session.me?.ui_preferences`
|
||||
* (moved from public `User.ui_preferences` to `SelfUser.ui_preferences`
|
||||
* as part of the three-layer UserDto refactor — the bag is self-only
|
||||
* state, not something other authenticated callers should see).
|
||||
* Signing in / out / refresh flips it in lockstep with the session.
|
||||
* Reads pass through DEFAULTS for any missing key.
|
||||
*/
|
||||
private bag = $derived<Record<string, unknown>>(
|
||||
(session.user?.ui_preferences as Record<string, unknown> | undefined) ?? {}
|
||||
(session.me?.ui_preferences as Record<string, unknown> | undefined) ?? {}
|
||||
);
|
||||
|
||||
// ── Typed accessors ──────────────────────────────────────────
|
||||
@@ -100,11 +103,14 @@ class PreferencesStore {
|
||||
* `jsonb_strip_nulls` after the merge).
|
||||
*/
|
||||
set(patch: Partial<Record<keyof UiPreferences, unknown>>): void {
|
||||
if (!session.user) return;
|
||||
if (!session.me) return;
|
||||
|
||||
// Optimistic local write — mutate the reactive user shallowly.
|
||||
// Optimistic local write — mutate the reactive me shallowly.
|
||||
// `ui_preferences` lives on `SelfUser` (self-only), not on the
|
||||
// public `User` slice, so the mutation stays at the SelfUser
|
||||
// level. The nested `full` / `full.user` blocks are untouched.
|
||||
const nextBag = {
|
||||
...((session.user.ui_preferences as Record<string, unknown> | undefined) ?? {}),
|
||||
...((session.me.ui_preferences as Record<string, unknown> | undefined) ?? {}),
|
||||
...patch
|
||||
};
|
||||
// Strip any explicit-null locally so the derived getters see the
|
||||
@@ -114,7 +120,7 @@ class PreferencesStore {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v === null) delete (nextBag as Record<string, unknown>)[k];
|
||||
}
|
||||
session.user = { ...session.user, ui_preferences: nextBag };
|
||||
session.me = { ...session.me, ui_preferences: nextBag };
|
||||
|
||||
// Accumulate keys so successive `set` calls before the debounce
|
||||
// fires collapse into a single PATCH body — matters for
|
||||
@@ -131,16 +137,20 @@ class PreferencesStore {
|
||||
this.pendingPatch = {};
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
|
||||
const previousUser = session.user;
|
||||
// `session.user` is a derived read-through on `session.me.full.user`
|
||||
// — the source of truth is `session.me: SelfUser`. Snapshot + assign
|
||||
// there so the optimistic update / rollback matches the store shape
|
||||
// (see `docs/plan/userdto-refactor.md` for the layering).
|
||||
const previousMe = session.me;
|
||||
try {
|
||||
const updated = await updateProfile({ ui_preferences: patch });
|
||||
session.user = updated;
|
||||
session.me = updated;
|
||||
} catch {
|
||||
// Roll back to whatever the server last confirmed. The
|
||||
// optimistic local mutation is discarded and the derived
|
||||
// `hideDotfiles` / other getters snap back on the next
|
||||
// reactivity tick.
|
||||
session.user = previousUser;
|
||||
session.me = previousMe;
|
||||
ui.notify(
|
||||
t('preferences.save_failed', "Couldn't save your preference. Please try again."),
|
||||
'error'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { User } from '$lib/api/types';
|
||||
import type { SelfUser } from '$lib/api/types';
|
||||
|
||||
// `vi.mock` is hoisted above imports, so the spy it references must be created
|
||||
// with `vi.hoisted` (a plain top-level const isn't initialised yet when the
|
||||
@@ -14,7 +14,14 @@ vi.mock('$lib/api/endpoints/auth', () => ({
|
||||
|
||||
import { session } from './session.svelte';
|
||||
|
||||
const userWithUsage = (used: number) => ({ storage_used_bytes: used }) as unknown as User;
|
||||
// `storage_used_bytes` moved to `FullUser` (embedded inside `SelfUser`)
|
||||
// as part of the three-layer UserDto refactor
|
||||
// (`docs/plan/userdto-refactor.md`). Build a minimal SelfUser shape that
|
||||
// satisfies the type checker without hand-populating every field the
|
||||
// production shape carries — the test only cares about the usage read
|
||||
// path (`session.me.full.storage_used_bytes`).
|
||||
const userWithUsage = (used: number) =>
|
||||
({ full: { storage_used_bytes: used } }) as unknown as SelfUser;
|
||||
|
||||
describe('session.refresh', () => {
|
||||
beforeEach(() => {
|
||||
@@ -25,7 +32,7 @@ describe('session.refresh', () => {
|
||||
it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => {
|
||||
fetchMeMock.mockResolvedValue(userWithUsage(2048));
|
||||
await session.refresh();
|
||||
expect(session.user?.storage_used_bytes).toBe(2048);
|
||||
expect(session.me?.full.storage_used_bytes).toBe(2048);
|
||||
});
|
||||
|
||||
it('leaves the current user intact when the probe returns null', async () => {
|
||||
@@ -33,7 +40,7 @@ describe('session.refresh', () => {
|
||||
await session.refresh();
|
||||
fetchMeMock.mockResolvedValue(null);
|
||||
await session.refresh();
|
||||
expect(session.user?.storage_used_bytes).toBe(2048);
|
||||
expect(session.me?.full.storage_used_bytes).toBe(2048);
|
||||
});
|
||||
|
||||
it('leaves the current user intact when the probe throws', async () => {
|
||||
@@ -41,6 +48,6 @@ describe('session.refresh', () => {
|
||||
await session.refresh();
|
||||
fetchMeMock.mockRejectedValue(new Error('network'));
|
||||
await session.refresh();
|
||||
expect(session.user?.storage_used_bytes).toBe(2048);
|
||||
expect(session.me?.full.storage_used_bytes).toBe(2048);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,17 +11,39 @@ import { setLogoutInProgress } from '$lib/api/client';
|
||||
import { hasSessionHint } from '$lib/api/csrf';
|
||||
import { seedNonceFromCookie } from '$lib/auth/dpop-proof';
|
||||
import { drives } from '$lib/stores/drives.svelte';
|
||||
import type { User } from '$lib/api/types';
|
||||
import type { PublicUser, SelfUser } from '$lib/api/types';
|
||||
import { ensureActiveUser } from '$lib/utils/localStoragePrefs';
|
||||
|
||||
/**
|
||||
* Session store — the authenticated user and derived flags.
|
||||
*
|
||||
* Post the three-layer UserDto refactor (`docs/plan/userdto-refactor.md`),
|
||||
* `/api/auth/me` returns `SelfUser` (composed:
|
||||
* `SelfUser.full.user: PublicUser`). Two shorthand accessors keep every
|
||||
* existing consumer readable:
|
||||
*
|
||||
* - `session.user` → `PublicUser` (via `me.full.user`). Every callsite
|
||||
* that read `session.user.username / email / id / role / image /
|
||||
* is_external / given_name / family_name / is_online` keeps working.
|
||||
* - `session.me` → full `SelfUser`. New code that needs self-only or
|
||||
* admin-visible fields (`has_password`, `is_dpop_bound`, `active`,
|
||||
* `ui_preferences`, `federation_kind`, `last_login_at`, quotas, …)
|
||||
* reads through `session.me.full.foo` or `session.me.foo`.
|
||||
*/
|
||||
class SessionStore {
|
||||
user = $state<User | null>(null);
|
||||
/** Full `/api/auth/me` payload. Null when unauthenticated. */
|
||||
me = $state<SelfUser | null>(null);
|
||||
loaded = $state(false);
|
||||
homeFolderId = $state<string | null>(null);
|
||||
homeFolderName = $state<string | null>(null);
|
||||
|
||||
isExternalUser = $derived(this.user?.is_external ?? false);
|
||||
isAuthenticated = $derived(this.user !== null);
|
||||
/** Public-identity shorthand — same fields any authenticated caller
|
||||
* can see. Every legacy `session.user.foo` read (username, email, id,
|
||||
* role, image, is_external, given_name, family_name, is_online) still
|
||||
* works via this derived accessor. */
|
||||
user = $derived<PublicUser | null>(this.me?.full.user ?? null);
|
||||
isExternalUser = $derived(this.me?.full.user.is_external ?? false);
|
||||
isAuthenticated = $derived(this.me !== null);
|
||||
/**
|
||||
* TRUE when the backend has set `force_password_change_at_next_login`
|
||||
* on this account — an admin picked a temporary password and the
|
||||
@@ -33,7 +55,7 @@ class SessionStore {
|
||||
* flag (or a malformed `/me` response) doesn't accidentally
|
||||
* quarantine every user.
|
||||
*/
|
||||
mustChangePassword = $derived(this.user?.force_password_change === true);
|
||||
mustChangePassword = $derived(this.me?.force_password_change === true);
|
||||
|
||||
/**
|
||||
* Resolve the session once. Probes /api/auth/me; on 401 it makes a single
|
||||
@@ -41,15 +63,15 @@ class SessionStore {
|
||||
* what to do with an unauthenticated result. Idempotent: subsequent calls
|
||||
* return the cached result (so client-side navigation doesn't re-probe).
|
||||
*/
|
||||
async load(): Promise<User | null> {
|
||||
if (this.loaded) return this.user;
|
||||
async load(): Promise<SelfUser | null> {
|
||||
if (this.loaded) return this.me;
|
||||
// No JS-visible session hint ⇒ nothing to probe. The server sets
|
||||
// `oxicloud_csrf` alongside the HttpOnly session cookies and clears
|
||||
// it on logout, so a missing hint means no session. Skips the
|
||||
// doomed 2× /me + /refresh burst that would otherwise fire on
|
||||
// every first landing / post-logout re-mount with no cookies.
|
||||
if (!hasSessionHint()) {
|
||||
this.user = null;
|
||||
this.me = null;
|
||||
this.loaded = true;
|
||||
return null;
|
||||
}
|
||||
@@ -71,25 +93,25 @@ class SessionStore {
|
||||
// otherwise clutter the audit stream. Fire-and-forget
|
||||
// so a slow IndexedDB open doesn't stall app boot.
|
||||
if (me.is_dpop_bound === false) void bindDpopIfPossible();
|
||||
} else this.user = null;
|
||||
} else this.me = null;
|
||||
} catch {
|
||||
this.user = null;
|
||||
this.me = null;
|
||||
}
|
||||
this.loaded = true;
|
||||
return this.user;
|
||||
return this.me;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the authenticated user AND run per-user localStorage cleanup
|
||||
* (see `$lib/utils/localStoragePrefs::ensureActiveUser`). Direct
|
||||
* `session.user = …` assignments skip the cleanup — always call
|
||||
* `session.me = …` assignments skip the cleanup — always call
|
||||
* `setUser` on login-flow entry points (form login, OIDC exchange,
|
||||
* existing-session probe) so a switch-account flow inside the same
|
||||
* tab observes the wipe.
|
||||
*/
|
||||
setUser(user: User): void {
|
||||
this.user = user;
|
||||
ensureActiveUser(user.id);
|
||||
setUser(me: SelfUser): void {
|
||||
this.me = me;
|
||||
ensureActiveUser(me.full.user.id);
|
||||
// Any successful login clears the session-teardown gate. Without
|
||||
// this, a logout → login within the same SPA session leaves the
|
||||
// gate stuck at `true` — the login POST is exempted via
|
||||
@@ -115,7 +137,7 @@ class SessionStore {
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const me = await fetchMe();
|
||||
if (me) this.user = me;
|
||||
if (me) this.me = me;
|
||||
} catch {
|
||||
/* keep the existing user on a transient /api/auth/me failure */
|
||||
}
|
||||
@@ -142,7 +164,7 @@ class SessionStore {
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.user = null;
|
||||
this.me = null;
|
||||
this.homeFolderId = null;
|
||||
this.homeFolderName = null;
|
||||
// Mark the store as `loaded` so any subsequent `session.load()` —
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
type StorageTestResult
|
||||
} from '$lib/api/endpoints/admin';
|
||||
import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives';
|
||||
import { seedUser } from '$lib/api/endpoints/users';
|
||||
import {
|
||||
ensureResolvers,
|
||||
resolveRecipient,
|
||||
@@ -70,7 +71,7 @@
|
||||
type Recipient
|
||||
} from '$lib/api/endpoints/recipients';
|
||||
import type {
|
||||
AdminUserSummary,
|
||||
FullUser,
|
||||
Drive,
|
||||
DriveMember,
|
||||
DrivePolicies,
|
||||
@@ -156,11 +157,11 @@
|
||||
deleteUserModal !== null &&
|
||||
deleteUserEmailInput.trim().toLowerCase() === deleteUserModal.email.toLowerCase()
|
||||
);
|
||||
function openDeleteUser(u: AdminUserSummary) {
|
||||
function openDeleteUser(u: FullUser) {
|
||||
deleteUserModal = {
|
||||
userId: u.id,
|
||||
username: u.username || u.email,
|
||||
email: u.email
|
||||
userId: u.user.id,
|
||||
username: u.user.username || u.user.email,
|
||||
email: u.user.email
|
||||
};
|
||||
deleteUserEmailInput = '';
|
||||
}
|
||||
@@ -817,7 +818,7 @@
|
||||
}
|
||||
|
||||
// Users
|
||||
let users = $state<AdminUserSummary[]>([]);
|
||||
let users = $state<FullUser[]>([]);
|
||||
let total = $state(0);
|
||||
let pageIndex = $state(0);
|
||||
let usersError = $state<string | null>(null);
|
||||
@@ -923,6 +924,12 @@
|
||||
const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE);
|
||||
users = page.users;
|
||||
total = page.total;
|
||||
// Seed the per-user resolver cache with the row's `PublicUser`
|
||||
// slice so every `UserVignette` mounted per row hits the cache
|
||||
// synchronously — no per-row `/api/users/{id}` follow-up.
|
||||
// Kills the N+1 that motivated widening `/api/admin/users` to
|
||||
// carry the avatar (docs/plan/userdto-refactor.md § N+1).
|
||||
for (const row of page.users) seedUser(row.user);
|
||||
} catch (e) {
|
||||
usersError = errorMessage(e);
|
||||
}
|
||||
@@ -1003,49 +1010,49 @@
|
||||
}
|
||||
|
||||
/** True for the signed-in admin's own row — guards self-destructive actions. */
|
||||
function isSelf(u: AdminUserSummary): boolean {
|
||||
return u.id === currentAdminId;
|
||||
function isSelf(u: FullUser): boolean {
|
||||
return u.user.id === currentAdminId;
|
||||
}
|
||||
/** OIDC/SSO-provisioned account (no local password to reset). */
|
||||
function isOidcUser(u: AdminUserSummary): boolean {
|
||||
function isOidcUser(u: FullUser): boolean {
|
||||
return u.federation_kind === 'oidc';
|
||||
}
|
||||
/** Used-quota percentage (0 when unlimited) for the per-user progress bar. */
|
||||
function quotaPct(u: AdminUserSummary): number {
|
||||
function quotaPct(u: FullUser): number {
|
||||
return u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0;
|
||||
}
|
||||
|
||||
async function toggleRole(u: AdminUserSummary) {
|
||||
async function toggleRole(u: FullUser) {
|
||||
if (isSelf(u)) return;
|
||||
const role = u.role === 'admin' ? 'user' : 'admin';
|
||||
const role = u.user.role === 'admin' ? 'user' : 'admin';
|
||||
if (!(await showConfirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?')))) return;
|
||||
try {
|
||||
await setUserRole(u.id, role);
|
||||
await setUserRole(u.user.id, role);
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(u: AdminUserSummary) {
|
||||
async function toggleActive(u: FullUser) {
|
||||
if (isSelf(u) && u.active) return;
|
||||
const msg = u.active
|
||||
? t('admin.confirm_deactivate', 'Deactivate this user?')
|
||||
: t('admin.confirm_activate', 'Activate this user?');
|
||||
if (!(await showConfirm(msg))) return;
|
||||
try {
|
||||
await setUserActive(u.id, !u.active);
|
||||
await setUserActive(u.user.id, !u.active);
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function openQuota(u: AdminUserSummary) {
|
||||
function openQuota(u: FullUser) {
|
||||
quotaModalError = null;
|
||||
quotaModal = {
|
||||
userId: u.id,
|
||||
username: u.username || u.email,
|
||||
userId: u.user.id,
|
||||
username: u.user.username || u.user.email,
|
||||
initialBytes: u.storage_quota_bytes
|
||||
};
|
||||
}
|
||||
@@ -1069,8 +1076,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openReset(u: AdminUserSummary) {
|
||||
resetModal = { userId: u.id, username: u.username || u.email };
|
||||
function openReset(u: FullUser) {
|
||||
resetModal = { userId: u.user.id, username: u.user.username || u.user.email };
|
||||
resetPassword = '';
|
||||
resetError = null;
|
||||
}
|
||||
@@ -1094,7 +1101,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function removeUser(u: AdminUserSummary) {
|
||||
function removeUser(u: FullUser) {
|
||||
if (isSelf(u)) return;
|
||||
openDeleteUser(u);
|
||||
}
|
||||
@@ -1103,20 +1110,20 @@
|
||||
// provisions a home drive + flips the is_external flag; irreversible
|
||||
// via the admin UI (there's no demote endpoint on purpose). Backend
|
||||
// refuses when magic-link login is disabled — surfaced as a toast.
|
||||
async function promoteExternal(u: AdminUserSummary) {
|
||||
if (!u.is_external) return;
|
||||
async function promoteExternal(u: FullUser) {
|
||||
if (!u.user.is_external) return;
|
||||
if (
|
||||
!(await showConfirm(
|
||||
t(
|
||||
'admin.confirm_promote_user',
|
||||
{ name: u.username || u.email },
|
||||
{ name: u.user.username || u.user.email },
|
||||
'Promote {{name}} to an internal user? This provisions a home drive and gives the account a normal storage envelope. The account keeps its identity; magic-link login stays the way in unless a password is set later.'
|
||||
)
|
||||
))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await promoteUserToInternal(u.id);
|
||||
await promoteUserToInternal(u.user.id);
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
reportError(e);
|
||||
@@ -1240,8 +1247,13 @@
|
||||
.map(async (d) => {
|
||||
const ownerMember = nextMembers[d.id]?.find((m) => m.subject.type === 'user');
|
||||
if (!ownerMember) return;
|
||||
const user = await getUserAdmin(ownerMember.subject.id);
|
||||
if (user) nextOwners[d.id] = user;
|
||||
// `getUserAdmin` returns `FullUser` (admin-visible extras
|
||||
// + nested `.user: PublicUser`). The drive row only reads
|
||||
// public-identity fields (username, email, image) so keep
|
||||
// the map typed as `PublicUser` and unwrap the embedded
|
||||
// public block on insert. See docs/plan/userdto-refactor.md.
|
||||
const full = await getUserAdmin(ownerMember.subject.id);
|
||||
if (full) nextOwners[d.id] = full.user;
|
||||
})
|
||||
);
|
||||
personalDriveOwners = nextOwners;
|
||||
@@ -1723,6 +1735,16 @@
|
||||
{:else if !dashboard}
|
||||
<p class="status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else}
|
||||
<!-- Three grouped sections — one per data-nature axis. Static
|
||||
row counts on top (change on register/deactivate/role toggle),
|
||||
live-presence signals in the middle (change minute-to-minute,
|
||||
visually distinguished with the presence dot), system flags at
|
||||
the bottom (deployment posture, changes rarely). Splitting
|
||||
what used to be a single 4-card row prevents admins from
|
||||
misreading "as-of-now count" as "who's here right now". -->
|
||||
|
||||
<!-- Section 1: User accounts — static breakdown of auth.users -->
|
||||
<h2 class="ds-section-title">{t('admin.section_accounts', 'User accounts')}</h2>
|
||||
<div class="ds-grid">
|
||||
<div class="ds-card">
|
||||
<span class="ds-num">{dashboard.total_users}</span>{t('admin.total_users', 'Total users')}
|
||||
@@ -1733,11 +1755,66 @@
|
||||
<div class="ds-card">
|
||||
<span class="ds-num">{dashboard.admin_users}</span>{t('admin.admin_users', 'Admins')}
|
||||
</div>
|
||||
<div class="ds-card">
|
||||
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
|
||||
<div
|
||||
class="ds-card"
|
||||
title={t(
|
||||
'admin.external_users_tooltip',
|
||||
'Grant-only accounts — magic-link, OIDC-only, OCM recipients'
|
||||
)}
|
||||
>
|
||||
<span class="ds-num">{dashboard.external_users}</span>{t(
|
||||
'admin.external_users',
|
||||
'External'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Live activity — projection over auth.sessions.
|
||||
Same 5-min window as the Prometheus `oxicloud_sessions_online`
|
||||
gauges. The presence dot before each number signals "this
|
||||
value changes minute-to-minute" — same green as the
|
||||
admin > sessions row indicator so admins read one consistent
|
||||
visual for presence across the panel. -->
|
||||
<h2 class="ds-section-title">
|
||||
{t('admin.section_activity', 'Live activity')}
|
||||
<span
|
||||
class="ds-section-live"
|
||||
title={t('admin.live_tooltip', 'Reflects sessions active in the last 5 minutes')}
|
||||
>
|
||||
{t('admin.live', 'live')}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="ds-grid">
|
||||
<div
|
||||
class="ds-card"
|
||||
title={t(
|
||||
'admin.online_users_tooltip',
|
||||
'Distinct users with a session active in the last 5 minutes'
|
||||
)}
|
||||
>
|
||||
<span class="ds-num ds-num--live">
|
||||
<span class="presence-dot presence-dot--online" aria-hidden="true"></span>
|
||||
{dashboard.online_users}
|
||||
</span>
|
||||
{t('admin.online_users', 'Online users')}
|
||||
</div>
|
||||
<div
|
||||
class="ds-card"
|
||||
title={t(
|
||||
'admin.online_sessions_tooltip',
|
||||
'Non-revoked sessions active in the last 5 minutes — multi-device users contribute more than one'
|
||||
)}
|
||||
>
|
||||
<span class="ds-num ds-num--live">
|
||||
<span class="presence-dot presence-dot--online" aria-hidden="true"></span>
|
||||
{dashboard.online_sessions}
|
||||
</span>
|
||||
{t('admin.online_sessions', 'Online sessions')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: System — deployment flags + version. -->
|
||||
<h2 class="ds-section-title">{t('admin.section_system', 'System')}</h2>
|
||||
<div class="ds-grid">
|
||||
<div class="ds-card">
|
||||
<span class="ds-flag" class:ds-flag--on={dashboard.auth_enabled}>
|
||||
@@ -1761,6 +1838,9 @@
|
||||
</span>
|
||||
{t('admin.quotas', 'Quotas')}
|
||||
</div>
|
||||
<div class="ds-card">
|
||||
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if dashboard.users_over_quota > 0}
|
||||
@@ -1802,13 +1882,17 @@
|
||||
row.kind === 'personal'
|
||||
? t('admin.quota_personal', 'Personal drives')
|
||||
: t('admin.quota_shared', 'Shared drives')}
|
||||
{@const total = row.unlimited_count + row.capped_count}
|
||||
{@const pct =
|
||||
row.capped_quota_bytes && row.capped_quota_bytes > 0
|
||||
? (row.used_bytes / row.capped_quota_bytes) * 100
|
||||
: null}
|
||||
{#if row.capped_count > 0 || row.unlimited_count > 0}
|
||||
<tr>
|
||||
<th scope="row">{label}</th>
|
||||
<th scope="row">
|
||||
<span class="quota-table__count">{total}</span>
|
||||
{label}
|
||||
</th>
|
||||
<td class="quota-table__num">
|
||||
{#if row.capped_quota_bytes !== null && pct !== null}
|
||||
{formatBytes(row.used_bytes)} / {formatBytes(row.capped_quota_bytes)}
|
||||
@@ -2680,15 +2764,15 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as u (u.id)}
|
||||
{#each users as u (u.user.id)}
|
||||
{@const pct = quotaPct(u)}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-vignette-cell">
|
||||
<UserVignette
|
||||
userId={u.id}
|
||||
fallbackLabel={u.username || u.email}
|
||||
fallbackSublabel={u.email}
|
||||
userId={u.user.id}
|
||||
fallbackLabel={u.user.username || u.user.email}
|
||||
fallbackSublabel={u.user.email}
|
||||
/>
|
||||
{#if isSelf(u)}
|
||||
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
|
||||
@@ -2703,11 +2787,11 @@
|
||||
badge is `white-space: nowrap` so the badge label
|
||||
itself never wraps mid-word either. -->
|
||||
<div class="role-badges">
|
||||
<span class="badge badge--{u.role === 'admin' ? 'admin' : 'user'}">
|
||||
{#if u.role === 'admin'}<Icon name="shield-alt" />{/if}
|
||||
{u.role}
|
||||
<span class="badge badge--{u.user.role === 'admin' ? 'admin' : 'user'}">
|
||||
{#if u.user.role === 'admin'}<Icon name="shield-alt" />{/if}
|
||||
{u.user.role}
|
||||
</span>
|
||||
{#if u.is_external}
|
||||
{#if u.user.is_external}
|
||||
<!-- Origin flag, orthogonal to `role`. Grant-only
|
||||
accounts (magic-link / OCM) can never be admin
|
||||
(DB CHECK `users_external_not_admin`) so the two
|
||||
@@ -2738,7 +2822,7 @@
|
||||
<td class="auth-cell">
|
||||
<!--
|
||||
Auth-capability chip set — ADMIN-ONLY (fields
|
||||
scoped to `AdminUserSummaryDto`; never on
|
||||
scoped to `FullUserDto`; never on
|
||||
`UserDto`). Any user carries ZERO OR MORE of:
|
||||
* SSO/OIDC — `federation_kind === 'oidc'`,
|
||||
identity delegated to the IdP; label is
|
||||
@@ -2758,8 +2842,8 @@
|
||||
-->
|
||||
{#if isOidcUser(u)}
|
||||
<span class="badge badge--oidc" title={u.federation_issuer}>
|
||||
<Icon name="key" />
|
||||
<span class="badge__label">{u.federation_issuer}</span>
|
||||
<Icon name="shield-alt" />
|
||||
<span class="badge__label">oidc</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if u.has_password}
|
||||
@@ -2825,7 +2909,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if u.is_external}
|
||||
{#if u.user.is_external}
|
||||
<!-- External accounts have no storage envelope by
|
||||
design (DB CHECK `users_external_no_storage`
|
||||
enforces storage_quota_bytes = 0). Rendering the
|
||||
@@ -2867,10 +2951,10 @@
|
||||
actions render as invisible placeholders. -->
|
||||
<div class="actions actions--user">
|
||||
<!-- Slot 1: quota (internal) OR promote (external). -->
|
||||
{#if u.is_external}
|
||||
{#if u.user.is_external}
|
||||
<button
|
||||
class="icon-btn icon-btn--success"
|
||||
data-testid={`admin-user-promote-${u.id}`}
|
||||
data-testid={`admin-user-promote-${u.user.id}`}
|
||||
title={t('admin.promote_to_internal_title', 'Promote to internal user')}
|
||||
aria-label={t('admin.promote_to_internal_title', 'Promote to internal user')}
|
||||
onclick={() => promoteExternal(u)}
|
||||
@@ -2880,7 +2964,7 @@
|
||||
{:else}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-quota-${u.id}`}
|
||||
data-testid={`admin-user-quota-${u.user.id}`}
|
||||
title={t('admin.edit_quota_title', 'Edit quota')}
|
||||
aria-label={t('admin.edit_quota_title', 'Edit quota')}
|
||||
onclick={() => openQuota(u)}
|
||||
@@ -2891,10 +2975,10 @@
|
||||
<!-- Slot 2: reset password (local internal only —
|
||||
OIDC and external accounts have no password
|
||||
to reset). Placeholder otherwise. -->
|
||||
{#if !isOidcUser(u) && !u.is_external}
|
||||
{#if !isOidcUser(u) && !u.user.is_external}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-reset-password-${u.id}`}
|
||||
data-testid={`admin-user-reset-password-${u.user.id}`}
|
||||
title={t('admin.reset_password_title', 'Reset password')}
|
||||
aria-label={t('admin.reset_password_title', 'Reset password')}
|
||||
onclick={() => openReset(u)}
|
||||
@@ -2909,16 +2993,16 @@
|
||||
`change_user_role` + DB CHECK
|
||||
`users_external_not_admin`). Promotion to
|
||||
internal is offered separately in slot 1. -->
|
||||
{#if !u.is_external}
|
||||
{#if !u.user.is_external}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-toggle-role-${u.id}`}
|
||||
data-testid={`admin-user-toggle-role-${u.user.id}`}
|
||||
title={t('admin.toggle_role_title', 'Toggle admin role')}
|
||||
aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
|
||||
disabled={isSelf(u)}
|
||||
onclick={() => toggleRole(u)}
|
||||
>
|
||||
<Icon name={u.role === 'admin' ? 'user' : 'crown'} />
|
||||
<Icon name={u.user.role === 'admin' ? 'user' : 'crown'} />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="icon-btn icon-btn--placeholder" aria-hidden="true"></span>
|
||||
@@ -2926,7 +3010,7 @@
|
||||
<!-- Slot 4: activate/deactivate. -->
|
||||
<button
|
||||
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
|
||||
data-testid={`admin-user-toggle-active-${u.id}`}
|
||||
data-testid={`admin-user-toggle-active-${u.user.id}`}
|
||||
title={u.active
|
||||
? t('admin.deactivate_title', 'Deactivate')
|
||||
: t('admin.activate_title', 'Activate')}
|
||||
@@ -2941,7 +3025,7 @@
|
||||
<!-- Slot 5: delete. -->
|
||||
<button
|
||||
class="icon-btn icon-btn--danger"
|
||||
data-testid={`admin-user-delete-${u.id}`}
|
||||
data-testid={`admin-user-delete-${u.user.id}`}
|
||||
title={t('admin.delete_title', 'Delete user')}
|
||||
aria-label={t('admin.delete_title', 'Delete user')}
|
||||
disabled={isSelf(u)}
|
||||
@@ -3082,6 +3166,36 @@
|
||||
{t('admin.sessions.expired', 'expired')}
|
||||
</span>
|
||||
{:else}
|
||||
<!--
|
||||
Presence dot — filled green when the server saw a
|
||||
request in the last 5 min (backend `is_online`),
|
||||
outlined grey otherwise. Only rendered on active
|
||||
rows: a revoked-but-recently-seen row would otherwise
|
||||
flash green post-revocation. Tooltip carries the
|
||||
human "last seen X ago" so admins don't have to
|
||||
hover-hunt for the exact timestamp — the
|
||||
`last_seen_at` DateTime is available as
|
||||
`title` for the details-on-demand case.
|
||||
-->
|
||||
<span
|
||||
class="presence-dot"
|
||||
class:presence-dot--online={s.is_online}
|
||||
class:presence-dot--idle={!s.is_online}
|
||||
title={s.is_online
|
||||
? t(
|
||||
'admin.sessions.presence_online_tooltip',
|
||||
{ ago: timeAgo(s.last_seen_at) },
|
||||
'Online — last seen {{ago}}'
|
||||
)
|
||||
: t(
|
||||
'admin.sessions.presence_idle_tooltip',
|
||||
{ ago: timeAgo(s.last_seen_at) },
|
||||
'Idle — last seen {{ago}}'
|
||||
)}
|
||||
aria-label={s.is_online
|
||||
? t('admin.sessions.online', 'online')
|
||||
: t('admin.sessions.idle', 'idle')}
|
||||
></span>
|
||||
<span class="badge badge--active">
|
||||
{t('admin.sessions.active', 'active')}
|
||||
</span>
|
||||
@@ -3245,11 +3359,21 @@
|
||||
fall back to the owner's cap; 0 also means "no limit"
|
||||
(backend convention — see `User.storage_quota_bytes` doc).
|
||||
-->
|
||||
<!-- Personal-drive fallback used to read
|
||||
`owner.storage_quota_bytes` off the resolved DTO.
|
||||
Post the UserDto refactor
|
||||
(docs/plan/userdto-refactor.md) `owner` here is a
|
||||
`PublicUser` (public identity, no quota); the
|
||||
envelope quota only lives on `FullUser` /
|
||||
`SelfUser`. Rather than widen the resolver's shape
|
||||
just for this fallback, hold the effective quota at
|
||||
`null` when the drive itself doesn't declare one —
|
||||
the row renders "—" and the admin can consult the
|
||||
user's row for their envelope cap. Explicit
|
||||
shared-drive quota still surfaces as before. -->
|
||||
{@const effectiveQuota =
|
||||
d.kind === 'personal'
|
||||
? owner && owner.storage_quota_bytes > 0
|
||||
? owner.storage_quota_bytes
|
||||
: null
|
||||
? null
|
||||
: d.quota_bytes && d.quota_bytes > 0
|
||||
? d.quota_bytes
|
||||
: null}
|
||||
@@ -4293,6 +4417,40 @@
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Section title bar above each dashboard grid — labels the
|
||||
nature of the cards below (accounts vs live activity vs
|
||||
system). Small, muted, so it structures the page without
|
||||
competing with the numbers. `text-transform: uppercase` +
|
||||
`letter-spacing` matches the small-caps section-header pattern
|
||||
used elsewhere in the admin surface. */
|
||||
.ds-section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-4) 0 var(--space-2) 0;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* "live" pill next to the "Live activity" section header —
|
||||
subtle visual hint that the values in this grid change on
|
||||
their own cadence. Matches the presence-dot's success token
|
||||
so the whole live-activity block reads as one visual family. */
|
||||
.ds-section-live {
|
||||
display: inline-block;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success-text);
|
||||
font-size: 0.65rem;
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: 0.08em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ds-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4311,6 +4469,18 @@
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
/* Live-count variant — same font size as `.ds-num`, plus a
|
||||
flex container so the leading presence dot aligns with the
|
||||
number baseline instead of the top of the digit. Reuses the
|
||||
`.presence-dot--online` class from the sessions-panel work
|
||||
so the visual signal for presence is identical across the
|
||||
admin surface. */
|
||||
.ds-num.ds-num--live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ds-bar {
|
||||
height: 8px;
|
||||
background: var(--color-bg-muted);
|
||||
@@ -4403,6 +4573,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Prepended drive count: tabular-nums so single/double/triple digits align
|
||||
vertically across rows; right-aligned inside a fixed-width box so the
|
||||
ones-digits line up across "personal" and "shared" rows regardless of
|
||||
how many digits each count has. */
|
||||
.quota-table__count {
|
||||
display: inline-block;
|
||||
min-width: 1.5em;
|
||||
margin-right: 0.25em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.quota-table__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
@@ -4572,6 +4755,46 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Presence dot in the sessions-table Status column — filled green
|
||||
when the row is `is_online` (a request landed in the last 5 min),
|
||||
outlined grey when the row is active-but-idle. Only rendered on
|
||||
active rows: a revoked-but-recently-seen row must never flash
|
||||
green post-revocation (see the markup guard `{#if s.is_active}`).
|
||||
The dot sits BEFORE the `active` badge with a small gap, so the
|
||||
Status cell reads left-to-right as `● active` when online and
|
||||
`○ active` when idle.
|
||||
|
||||
Tokens: `--color-success-alt` / `--color-success-border` for the
|
||||
filled fill is the same green used by `.badge--active`, keeping
|
||||
the presence signal visually consistent with the lifecycle one
|
||||
without stealing the badge's own colour treatment. Grey border
|
||||
for the idle state uses the neutral `--color-border` token so
|
||||
both themes (light + dark, driven by `light-dark(...)`) get a
|
||||
readable contrast. Fixed 8px / 8px sizing — the dot is a signal,
|
||||
not a click target, so relative units would over-scale on
|
||||
larger UI densities. */
|
||||
.presence-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: var(--space-1);
|
||||
vertical-align: middle;
|
||||
/* No border on the filled state, so both variants render at
|
||||
the same 8×8 footprint (the outlined variant's 1px border
|
||||
is inset via box-sizing: border-box below). */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.presence-dot--online {
|
||||
background: var(--color-success-alt);
|
||||
}
|
||||
|
||||
.presence-dot--idle {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* External / grant-only account marker. Sibling of `.badge--user`
|
||||
in the same cell so the two stack horizontally; the accent
|
||||
colour reuses `--color-warning-*` because "external" is the
|
||||
@@ -5143,9 +5366,17 @@
|
||||
}
|
||||
|
||||
.admin {
|
||||
max-width: 64rem;
|
||||
/* Raised from 64rem to 80rem so the data-dense tables (sessions
|
||||
row with 9+ cells, users table with vignette + role + auth
|
||||
chips + quota bar) have more horizontal room. At viewports
|
||||
above 80rem `margin: 0 auto` still centers with the leftover
|
||||
whitespace — DevTools shows that whitespace as horizontal
|
||||
margin (not padding) and is what "content looks squeezed"
|
||||
really means on wide displays. Vertical rhythm and the small
|
||||
horizontal padding are unchanged. */
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
padding: 1.5rem var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -96,16 +96,26 @@ const dashboard = {
|
||||
users_over_quota: 0
|
||||
};
|
||||
|
||||
// FullUser fixture — post the three-layer UserDto refactor
|
||||
// (docs/plan/userdto-refactor.md), /api/admin/users returns
|
||||
// `Vec<FullUserDto>` where public identity nests under `.user`
|
||||
// and admin-visible extras (quotas, active, has_password, OPAQUE
|
||||
// flags) live at the top level.
|
||||
const user = {
|
||||
id: 'u1',
|
||||
username: 'bob',
|
||||
email: 'bob@x.test',
|
||||
role: 'user',
|
||||
user: {
|
||||
id: 'u1',
|
||||
username: 'bob',
|
||||
email: 'bob@x.test',
|
||||
role: 'user',
|
||||
is_external: false
|
||||
},
|
||||
active: true,
|
||||
is_active: true,
|
||||
storage_used_bytes: 10,
|
||||
storage_quota_bytes: 100,
|
||||
is_external: false
|
||||
has_password: true,
|
||||
opaque_registered: false,
|
||||
opaque_migrated: false
|
||||
};
|
||||
|
||||
const mount = {
|
||||
|
||||
@@ -713,8 +713,43 @@
|
||||
* Upload a batch of files into the current folder, reporting aggregate
|
||||
* progress through a single bell notification with a progress bar.
|
||||
*/
|
||||
/**
|
||||
* Cold-navigation upload guard.
|
||||
*
|
||||
* `currentId` starts `null` and is only populated inside `load()` AFTER
|
||||
* `session.loadHomeFolder()` resolves (see the `$effect` at the bottom of
|
||||
* this file that drives `load()`, and the assignment at `currentId =
|
||||
* folderId` inside `load()`). The hidden `<input data-testid=
|
||||
* "files-upload-file-input">` is unconditional in the template, so it's
|
||||
* in the DOM the moment the page shell mounts — before `load()` has
|
||||
* awaited its first HTTP round-trip.
|
||||
*
|
||||
* On a slow network / cold page / Playwright cold `page.goto` immediately
|
||||
* followed by `setInputFiles`, `onchange` can fire while `currentId` is
|
||||
* still `null`. Without this guard, `uploadBatch` / `uploadTree` post
|
||||
* with `folderId: null` and the file silently lands in the caller's
|
||||
* home root instead of the intended folder — a real user hitting Ctrl+U
|
||||
* or dropping a file within ~100 ms of navigation hits the same window.
|
||||
*
|
||||
* The e2e reproduction: `tests/e2e/spa/files.spec.ts::"upload a file via
|
||||
* the hidden file input"` flakes on CI where the mount→load round-trip
|
||||
* outruns Playwright's file-input dispatch.
|
||||
*
|
||||
* Returns `true` when it's safe to proceed; `false` + a user-visible
|
||||
* toast when the folder isn't ready.
|
||||
*/
|
||||
function guardUploadFolderReady(): boolean {
|
||||
if (currentId !== null) return true;
|
||||
ui.notify(
|
||||
t('files.upload_folder_not_ready', 'Folder is still loading — please try again in a moment.'),
|
||||
'warning'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function uploadBatch(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
if (!guardUploadFolderReady()) return;
|
||||
uploading = true;
|
||||
// Arm the reload-guard + persist a "batch in flight" marker so a
|
||||
// page refresh mid-upload (a) prompts the browser's "Leave site?"
|
||||
@@ -1557,6 +1592,7 @@
|
||||
*/
|
||||
async function uploadTree(entries: { file: File; relativePath: string }[]) {
|
||||
if (entries.length === 0) return;
|
||||
if (!guardUploadFolderReady()) return;
|
||||
uploading = true;
|
||||
// Same reload-guard + interrupted-uploads breadcrumb as uploadBatch —
|
||||
// the browser prompts on refresh, and if the user reloads anyway
|
||||
|
||||
@@ -158,6 +158,12 @@ it('keeps aggregate upload progress exact when one file restarts', async () => {
|
||||
);
|
||||
render(FilesPage);
|
||||
const input = await screen.findByTestId('files-upload-file-input');
|
||||
// The cold-navigation upload guard (`guardUploadFolderReady` in
|
||||
// `+page.svelte`) refuses uploads while `currentId` is null — which is
|
||||
// the initial state before `load()` runs. `load()` sets `currentId =
|
||||
// folderId` BEFORE it calls `fetchFolderPage`, so waiting on the fetch
|
||||
// mock is a stable "load() has progressed past the assignment" signal.
|
||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||
const uploads = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')];
|
||||
Object.defineProperty(input, 'files', { configurable: true, value: uploads });
|
||||
|
||||
|
||||
@@ -72,11 +72,11 @@
|
||||
let creatingPw = $state(false);
|
||||
let autoExpanded = $state(false);
|
||||
|
||||
const isOidc = $derived(session.user?.federation_kind === 'oidc');
|
||||
const isLocal = $derived(!session.user?.federation_kind);
|
||||
const isOidc = $derived(session.me?.full.federation_kind === 'oidc');
|
||||
const isLocal = $derived(!session.me?.full.federation_kind);
|
||||
const usernameClaimed = $derived(!!session.user?.username);
|
||||
const isAdmin = $derived(session.user?.role === 'admin');
|
||||
const canEditImage = $derived(session.user?.can_edit_image === true && isLocal);
|
||||
const canEditImage = $derived(session.me?.can_edit_image === true && isLocal);
|
||||
// Show the change-password card when the user CAN change their
|
||||
// local password: they have `password_hash` on file AND the
|
||||
// deployment offers password login (backend `change_password`
|
||||
@@ -87,14 +87,16 @@
|
||||
// password) are a legitimate posture and MUST be able to rotate
|
||||
// their local credential; the new gate lets them, and the backend
|
||||
// refusal covers the pure-SSO case where has_password is false.
|
||||
const showPasswordCard = $derived((session.user?.has_password ?? false) && passwordLoginEnabled);
|
||||
const showPasswordCard = $derived(
|
||||
(session.me?.full.has_password ?? false) && passwordLoginEnabled
|
||||
);
|
||||
|
||||
// SSO card gates — see docs/plan/oidc-account-linking.md.
|
||||
// Connect: only when OIDC is enabled AND the user isn't already linked.
|
||||
// Disconnect: only when currently OIDC-linked AND the user has an
|
||||
// alternative auth method (password or OPAQUE-registered) — else
|
||||
// unlinking would lock them out.
|
||||
const canConnectSso = $derived(oidcEnabled && !session.user?.federation_kind);
|
||||
const canConnectSso = $derived(oidcEnabled && !session.me?.full.federation_kind);
|
||||
// Show the disconnect button whenever the user is OIDC-linked.
|
||||
// The backend guard (`AuthApplicationService::unlink_oidc`) is the
|
||||
// source of truth for the "no alternative auth" refusal — it also
|
||||
@@ -103,7 +105,7 @@
|
||||
// adoption status through user-directory endpoints). The UI shows
|
||||
// the button unconditionally and surfaces the backend's 403 as a
|
||||
// user-facing "set a password first" prompt.
|
||||
const canDisconnectSso = $derived(session.user?.federation_kind === 'oidc');
|
||||
const canDisconnectSso = $derived(session.me?.full.federation_kind === 'oidc');
|
||||
|
||||
/**
|
||||
* Mandatory change-password mode. TRUE when the backend has
|
||||
@@ -136,14 +138,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
const storagePct = $derived(
|
||||
session.user && session.user.storage_quota_bytes > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100)
|
||||
)
|
||||
: 0
|
||||
);
|
||||
const storagePct = $derived.by(() => {
|
||||
const full = session.me?.full;
|
||||
if (!full || full.storage_quota_bytes <= 0) return 0;
|
||||
return Math.min(100, Math.round((full.storage_used_bytes / full.storage_quota_bytes) * 100));
|
||||
});
|
||||
const storageBarClass = $derived(
|
||||
storagePct > 90 ? 'bar__fill--red' : storagePct > 70 ? 'bar__fill--orange' : 'bar__fill--green'
|
||||
);
|
||||
@@ -159,15 +158,20 @@
|
||||
relativeTimeAgo(value, { empty: t('profile.never', 'Never'), invalidAsString: true });
|
||||
|
||||
function hydrate() {
|
||||
const u = session.user;
|
||||
if (!u) return;
|
||||
givenName = u.given_name ?? '';
|
||||
familyName = u.family_name ?? '';
|
||||
username = u.username ?? '';
|
||||
preferredLocale = u.preferred_locale ?? '';
|
||||
notifyOnShare = u.notify_on_share;
|
||||
const me = session.me;
|
||||
if (!me) return;
|
||||
// Public identity (name / handle) reads via `me.full.user`;
|
||||
// admin-visible extras (preferred_locale) via `me.full`;
|
||||
// self-only bag flags (notify_on_share) via `me` directly.
|
||||
// The three-level indirection makes the audience of each
|
||||
// field visible at the callsite (docs/plan/userdto-refactor.md).
|
||||
givenName = me.full.user.given_name ?? '';
|
||||
familyName = me.full.user.family_name ?? '';
|
||||
username = me.full.user.username ?? '';
|
||||
preferredLocale = me.full.preferred_locale ?? '';
|
||||
notifyOnShare = me.notify_on_share;
|
||||
// Source of truth is the preferences store, which itself
|
||||
// derives from `session.user.ui_preferences`. Reading through
|
||||
// derives from `session.me.ui_preferences`. Reading through
|
||||
// the store here (rather than the raw bag) means a new
|
||||
// preference field just needs a getter in the store and its
|
||||
// own line here — no wire-format knowledge on the page.
|
||||
@@ -176,21 +180,22 @@
|
||||
|
||||
async function saveProfile(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const u = session.user;
|
||||
if (!u) return;
|
||||
const me = session.me;
|
||||
if (!me) return;
|
||||
|
||||
// Build a sparse patch of only the fields the user actually changed.
|
||||
// Sending empty strings the user never touched would 400 on the server.
|
||||
const patch: ProfilePatch = {};
|
||||
if (!usernameClaimed && username.trim() && username.trim() !== (u.username ?? '')) {
|
||||
if (!usernameClaimed && username.trim() && username.trim() !== (me.full.user.username ?? '')) {
|
||||
patch.username = username.trim();
|
||||
}
|
||||
if (givenName.trim() !== (u.given_name ?? '')) patch.given_name = givenName.trim();
|
||||
if (familyName.trim() !== (u.family_name ?? '')) patch.family_name = familyName.trim();
|
||||
if ((preferredLocale || '') !== (u.preferred_locale ?? '')) {
|
||||
if (givenName.trim() !== (me.full.user.given_name ?? '')) patch.given_name = givenName.trim();
|
||||
if (familyName.trim() !== (me.full.user.family_name ?? ''))
|
||||
patch.family_name = familyName.trim();
|
||||
if ((preferredLocale || '') !== (me.full.preferred_locale ?? '')) {
|
||||
patch.preferred_locale = preferredLocale || undefined;
|
||||
}
|
||||
if (notifyOnShare !== u.notify_on_share) patch.notify_on_share = notifyOnShare;
|
||||
if (notifyOnShare !== me.notify_on_share) patch.notify_on_share = notifyOnShare;
|
||||
// Ship the diff as a partial `ui_preferences` patch — the
|
||||
// server does a shallow merge, so only the changed key is
|
||||
// touched; siblings set on other devices survive.
|
||||
@@ -205,8 +210,13 @@
|
||||
|
||||
savingProfile = true;
|
||||
try {
|
||||
// PATCH /me/profile echoes SelfUser (same shape as GET /me)
|
||||
// so the SPA absorbs the just-written state in one round
|
||||
// trip — no follow-up refresh needed. `session.user` is a
|
||||
// derived accessor over `session.me.full.user`, so it
|
||||
// updates in lockstep with the me assignment.
|
||||
const updated = await updateProfile(patch);
|
||||
session.user = updated;
|
||||
session.me = updated;
|
||||
if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale);
|
||||
ui.notify(t('profile.saved', 'Profile saved'), 'success');
|
||||
} catch (err) {
|
||||
@@ -445,7 +455,7 @@
|
||||
// (federation_kind should now be 'oidc').
|
||||
try {
|
||||
const me = await fetchMe();
|
||||
if (me) session.user = me;
|
||||
if (me) session.me = me;
|
||||
} catch {
|
||||
/* stale session is recoverable — next request refreshes */
|
||||
}
|
||||
@@ -536,7 +546,7 @@
|
||||
try {
|
||||
await unlinkOidc();
|
||||
const me = await fetchMe();
|
||||
if (me) session.user = me;
|
||||
if (me) session.me = me;
|
||||
ui.notify(t('profile.sso_unlinked_success', 'Single sign-on disconnected.'), 'info');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.errorType === 'NoAlternativeAuth') {
|
||||
@@ -742,7 +752,7 @@
|
||||
<Icon name="clock" />
|
||||
{t('profile.last_login', 'Last Login')}
|
||||
</div>
|
||||
<div class="info-value">{timeAgo(session.user.last_login_at)}</div>
|
||||
<div class="info-value">{timeAgo(session.me?.full.last_login_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -752,20 +762,20 @@
|
||||
<h2><Icon name="hdd" /> {t('profile.storage', 'Storage')}</h2>
|
||||
<div class="storage-stats">
|
||||
<div class="storage-stat">
|
||||
<div class="stat-value">{formatBytes(session.user.storage_used_bytes)}</div>
|
||||
<div class="stat-value">{formatBytes(session.me?.full.storage_used_bytes ?? 0)}</div>
|
||||
<div class="muted">{t('profile.used', 'Used')}</div>
|
||||
</div>
|
||||
<div class="storage-stat">
|
||||
<div class="stat-value">
|
||||
{session.user.storage_quota_bytes > 0
|
||||
? formatBytes(session.user.storage_quota_bytes)
|
||||
{(session.me?.full.storage_quota_bytes ?? 0) > 0
|
||||
? formatBytes(session.me?.full.storage_quota_bytes ?? 0)
|
||||
: '∞'}
|
||||
</div>
|
||||
<div class="muted">{t('profile.quota', 'Quota')}</div>
|
||||
</div>
|
||||
<div class="storage-stat">
|
||||
<div class="stat-value">
|
||||
{session.user.storage_quota_bytes > 0 ? `${storagePct}%` : '—'}
|
||||
{(session.me?.full.storage_quota_bytes ?? 0) > 0 ? `${storagePct}%` : '—'}
|
||||
</div>
|
||||
<div class="muted">{t('profile.usage', 'Usage')}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { session, ui } = vi.hoisted(() => ({
|
||||
session: {
|
||||
loaded: true,
|
||||
load: vi.fn(),
|
||||
// Test-double session store. Post the three-layer UserDto refactor
|
||||
// (docs/plan/userdto-refactor.md), production `session.user` is a
|
||||
// derived accessor over `session.me.full.user`. The stub here mirrors
|
||||
// that shape: `me` carries the whole SelfUser tree, and `user` mirrors
|
||||
// `me.full.user` so any legacy `session.user.foo` read on the tested
|
||||
// page keeps working through the mock without reproducing the derived
|
||||
// mechanism.
|
||||
const buildSelfMe = () => ({
|
||||
full: {
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
@@ -12,14 +17,41 @@ const { session, ui } = vi.hoisted(() => ({
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
is_external: false
|
||||
},
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
has_password: true
|
||||
}
|
||||
});
|
||||
|
||||
const { session, ui } = vi.hoisted(() => {
|
||||
const me = {
|
||||
full: {
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
is_external: false
|
||||
},
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false,
|
||||
has_password: true
|
||||
}
|
||||
},
|
||||
ui: { notify: vi.fn() }
|
||||
}));
|
||||
};
|
||||
return {
|
||||
session: {
|
||||
loaded: true,
|
||||
load: vi.fn(),
|
||||
me,
|
||||
user: me.full.user
|
||||
},
|
||||
ui: { notify: vi.fn() }
|
||||
};
|
||||
});
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
|
||||
@@ -44,20 +76,13 @@ const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the shared session each test (handlers may mutate session.user).
|
||||
// Reset the shared session each test (handlers may mutate session.me
|
||||
// on save / refresh). `me` is the SelfUser tree; `user` mirrors
|
||||
// `me.full.user` for legacy `session.user.foo` reads.
|
||||
session.loaded = true;
|
||||
session.user = {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false,
|
||||
has_password: true
|
||||
};
|
||||
const me = buildSelfMe();
|
||||
session.me = me;
|
||||
session.user = me.full.user;
|
||||
m(profile.listAppPasswords).mockResolvedValue([]);
|
||||
m(profile.updateProfile).mockResolvedValue(undefined);
|
||||
m(getOidcProviders).mockResolvedValue({ password_login_enabled: true });
|
||||
|
||||
@@ -1201,11 +1201,11 @@
|
||||
"progress_scanned_only_tooltip": "لا يوجد إجمالي متاح لهذا التشغيل (نشر شريط التقدم المسبق أو عدم قيام المستأجر بالإبلاغ عن موضوع قابل للعد).",
|
||||
"findings_present_tooltip": "قم بتوسيع هذا التشغيل لرؤية تفاصيل كل نتيجة.",
|
||||
"col_error": "خطأ",
|
||||
"col_kind": "عطوف",
|
||||
"col_kind": "نوع",
|
||||
"col_severity": "خطورة",
|
||||
"col_resource": "الموارد",
|
||||
"col_detail": "التفاصيل",
|
||||
"run": "يجري",
|
||||
"run": "تشغيل",
|
||||
"cancel": "يلغي",
|
||||
"refresh": "ينعش",
|
||||
"runs_title": "أشواط الأخيرة",
|
||||
@@ -1218,7 +1218,7 @@
|
||||
"every_min": "كل دقيقة",
|
||||
"every_sec": "كل ق",
|
||||
"outcome_ok": "نعم",
|
||||
"outcome_err": "يخطئ",
|
||||
"outcome_err": "خطأ",
|
||||
"outcome_issues": "مشاكل",
|
||||
"outcome_notices": "إشعارات",
|
||||
"n_findings": "‹النتائج",
|
||||
|
||||
@@ -1182,8 +1182,8 @@
|
||||
"gen_key": "Schlüssel generieren",
|
||||
"gen_key_warning": "Bewahren Sie diesen Schlüssel sicher auf. Bei Verlust sind die verschlüsselten Daten unwiederbringlich verloren.",
|
||||
"jobs": {
|
||||
"run_all_consistency": "Führen Sie alle Konsistenzprüfungen durch",
|
||||
"run_deep": "Lauf tief",
|
||||
"run_all_consistency": "Alle Konsistenzprüfungen ausführen",
|
||||
"run_deep": "Tiefenprüfung",
|
||||
"run_deep_hint": "Läuft auch langsame Varianten (Blob-Re-Hash, Bitrot-Erkennung).",
|
||||
"col_name": "Name",
|
||||
"col_cadence": "Kadenz",
|
||||
@@ -1205,7 +1205,7 @@
|
||||
"col_severity": "Schwere",
|
||||
"col_resource": "Ressource",
|
||||
"col_detail": "Detail",
|
||||
"run": "Laufen",
|
||||
"run": "Ausführen",
|
||||
"cancel": "Stornieren",
|
||||
"refresh": "Aktualisieren",
|
||||
"runs_title": "Aktuelle Läufe",
|
||||
@@ -1218,7 +1218,7 @@
|
||||
"every_min": "alle {{n}} Min",
|
||||
"every_sec": "alle {{n}} s",
|
||||
"outcome_ok": "OK",
|
||||
"outcome_err": "ähm",
|
||||
"outcome_err": "err",
|
||||
"outcome_issues": "Probleme",
|
||||
"outcome_notices": "Hinweise",
|
||||
"n_findings": "{{n}} Erkenntnisse",
|
||||
|
||||
@@ -546,6 +546,7 @@
|
||||
"empty_hidden_hint": "Files whose name starts with '.' are hidden. Toggle the setting to see them.",
|
||||
"show_hidden": "Show hidden files",
|
||||
"upload_dotfile_hidden": "{{n}} file(s) uploaded but hidden by your dotfile preference.",
|
||||
"upload_folder_not_ready": "Folder is still loading — please try again in a moment.",
|
||||
"rename_dotfile_hidden": "Renamed to '{{name}}' — now hidden by your preference.",
|
||||
"new_folder_dotfile_hidden": "Created folder '{{name}}' — hidden by your dotfile preference.",
|
||||
"dotfiles_hidden_toast": "Dotfiles hidden",
|
||||
@@ -836,6 +837,17 @@
|
||||
"total_users": "Total Users",
|
||||
"active_users": "Active Users",
|
||||
"admins": "Admins",
|
||||
"external_users": "External",
|
||||
"external_users_tooltip": "Grant-only accounts — magic-link, OIDC-only, OCM recipients",
|
||||
"online_users": "Online users",
|
||||
"online_users_tooltip": "Distinct users with a session active in the last 5 minutes",
|
||||
"online_sessions": "Online sessions",
|
||||
"online_sessions_tooltip": "Non-revoked sessions active in the last 5 minutes — multi-device users contribute more than one",
|
||||
"section_accounts": "User accounts",
|
||||
"section_activity": "Live activity",
|
||||
"section_system": "System",
|
||||
"live": "live",
|
||||
"live_tooltip": "Reflects sessions active in the last 5 minutes",
|
||||
"version": "Version",
|
||||
"storage_overview": "Storage Overview",
|
||||
"used": "Used",
|
||||
@@ -1136,6 +1148,10 @@
|
||||
"revoked": "revoked",
|
||||
"expired": "expired",
|
||||
"active": "active",
|
||||
"online": "online",
|
||||
"idle": "idle",
|
||||
"presence_online_tooltip": "Online — last seen {{ago}}",
|
||||
"presence_idle_tooltip": "Idle — last seen {{ago}}",
|
||||
"revoke": "Revoke",
|
||||
"empty": "No sessions match the current filter.",
|
||||
"revoke_self_confirm": "⚠️ This is YOUR current session. Revoking it will log YOU out immediately and you'll have to sign back in. Continue?",
|
||||
@@ -1259,6 +1275,20 @@
|
||||
"run_all_consistency": "Run all consistency checks",
|
||||
"run_deep": "Run deep",
|
||||
"run_deep_hint": "Also runs slow variants (blob re-hash, bitrot detection).",
|
||||
"run_repair": "Repair ref_counts",
|
||||
"run_repair_hint": "Corrects any drifted ref_counts (blobs + manifests) found by the audit. Content-safe — only counters change, not data.",
|
||||
"run_repair_confirm_title_scoped": "Run {{name}} in repair mode?",
|
||||
"run_mutating_confirm_title": "Run {{name}}?",
|
||||
"run_mutating_confirm_body": "This job changes stored state when it runs.",
|
||||
"mutates_never": "read-only",
|
||||
"mutates_on_repair_only": "read-only unless repaired",
|
||||
"startup": "at boot",
|
||||
"startup_repair": "at boot · repair",
|
||||
"startup_tooltip": "Configured in OXICLOUD_STARTUP_JOBS to run at every boot.",
|
||||
"startup_repair_tooltip": "Configured in OXICLOUD_STARTUP_JOBS to run in repair mode at every boot.",
|
||||
"run_variants_menu": "Run variants menu",
|
||||
"run_repair_confirm": "Repair",
|
||||
"triggered_ok_repair": "{{name}}: {{n}} counter(s) repaired",
|
||||
"col_name": "Name",
|
||||
"col_cadence": "Cadence",
|
||||
"col_last_run": "Last run",
|
||||
|
||||
@@ -1183,15 +1183,15 @@
|
||||
"time_just_now": "En este momento",
|
||||
"unchanged": "Déjelo en blanco para mantenerse actualizado",
|
||||
"jobs": {
|
||||
"run_all_consistency": "Ejecute todas las comprobaciones de coherencia",
|
||||
"run_deep": "Corre profundo",
|
||||
"run_all_consistency": "Ejecutar todas las comprobaciones de coherencia",
|
||||
"run_deep": "Análisis en profundidad",
|
||||
"run_deep_hint": "También ejecuta variantes lentas (repetición de blobs, detección de bitrot).",
|
||||
"col_name": "Nombre",
|
||||
"col_cadence": "Cadencia",
|
||||
"col_last_run": "última ejecución",
|
||||
"col_outcome": "Resultado",
|
||||
"col_state": "Estado",
|
||||
"col_actions": "Comportamiento",
|
||||
"col_actions": "Acciones",
|
||||
"col_started_at": "Comenzó",
|
||||
"col_status": "Estado",
|
||||
"col_duration": "Duración",
|
||||
@@ -1202,24 +1202,24 @@
|
||||
"progress_scanned_only_tooltip": "No hay un total disponible para esta ejecución (implementación previa a la barra de progreso o el inquilino no informa un asunto contable).",
|
||||
"findings_present_tooltip": "Amplíe esta ejecución para ver detalles por hallazgo.",
|
||||
"col_error": "Error",
|
||||
"col_kind": "Amable",
|
||||
"col_kind": "Tipo",
|
||||
"col_severity": "Gravedad",
|
||||
"col_resource": "Recurso",
|
||||
"col_detail": "Detalle",
|
||||
"run": "Correr",
|
||||
"run": "Ejecutar",
|
||||
"cancel": "Cancelar",
|
||||
"refresh": "Refrescar",
|
||||
"runs_title": "Ejecuciones recientes",
|
||||
"run_json": "Resumen de ejecución (JSON)",
|
||||
"findings_title": "Recomendaciones",
|
||||
"no_runs": "Aún no hay carreras.",
|
||||
"no_runs": "Aún no hay ejecuciones.",
|
||||
"no_findings": "No hay resultados: ejecución limpia.",
|
||||
"on_demand": "Bajo demanda",
|
||||
"every_h": "cada {{n}} horas",
|
||||
"every_min": "cada {{n}} minutos",
|
||||
"every_sec": "cada {{n}} s",
|
||||
"outcome_ok": "OK",
|
||||
"outcome_err": "errar",
|
||||
"outcome_err": "err",
|
||||
"outcome_issues": "asuntos",
|
||||
"outcome_notices": "avisos",
|
||||
"n_findings": "{{n}} hallazgos",
|
||||
|
||||
@@ -1165,7 +1165,7 @@
|
||||
"gen_key_warning": "این کلید را به صورت ایمن ذخیره کنید. اگر از بین برود، داده های رمزگذاری شده به طور غیرقابل جبرانی از بین می روند.",
|
||||
"jobs": {
|
||||
"run_all_consistency": "تمام بررسی های سازگاری را اجرا کنید",
|
||||
"run_deep": "عمیق بدو",
|
||||
"run_deep": "بررسی عمیق",
|
||||
"run_deep_hint": "همچنین انواع آهسته را اجرا می کند (هش مجدد حباب، تشخیص بیتوت).",
|
||||
"col_name": "نام",
|
||||
"col_cadence": "آهنگ",
|
||||
@@ -1182,7 +1182,7 @@
|
||||
"progress_scanned_only_tooltip": "مجموع برای این اجرا موجود نیست (پیش از پیشرفت نوار مستقر شده یا مستاجر موضوع قابل شمارش را گزارش نمی کند).",
|
||||
"findings_present_tooltip": "این اجرا را گسترش دهید تا جزئیات هر یافته را ببینید.",
|
||||
"col_error": "خطا",
|
||||
"col_kind": "مهربان",
|
||||
"col_kind": "نوع",
|
||||
"col_severity": "شدت",
|
||||
"col_resource": "منبع",
|
||||
"col_detail": "جزئیات",
|
||||
@@ -1199,7 +1199,7 @@
|
||||
"every_min": "هر {{n}} دقیقه",
|
||||
"every_sec": "هر {{n}} ثانیه",
|
||||
"outcome_ok": "باشه",
|
||||
"outcome_err": "اشتباه کن",
|
||||
"outcome_err": "خطا",
|
||||
"outcome_issues": "مسائل",
|
||||
"outcome_notices": "اطلاعیه ها",
|
||||
"n_findings": "{{n}} یافته ها",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"empty_hidden_hint": "Les fichiers dont le nom commence par '.' sont masqués. Modifiez le réglage pour les afficher.",
|
||||
"show_hidden": "Afficher les fichiers masqués",
|
||||
"upload_dotfile_hidden": "{{n}} fichier(s) téléversé(s) mais masqué(s) par votre préférence.",
|
||||
"upload_folder_not_ready": "Le dossier est encore en cours de chargement — merci de réessayer dans un instant.",
|
||||
"rename_dotfile_hidden": "Renommé en \"{{name}}\" — désormais masqué par votre préférence.",
|
||||
"new_folder_dotfile_hidden": "Dossier \"{{name}}\" créé — masqué par votre préférence.",
|
||||
"dotfiles_hidden_toast": "Fichiers masqués",
|
||||
@@ -801,6 +802,17 @@
|
||||
"total_users": "Utilisateurs totaux",
|
||||
"active_users": "Utilisateurs actifs",
|
||||
"admins": "Admins",
|
||||
"external_users": "Externes",
|
||||
"external_users_tooltip": "Comptes invités — magic-link, OIDC seulement, destinataires OCM",
|
||||
"online_users": "Utilisateurs en ligne",
|
||||
"online_users_tooltip": "Utilisateurs distincts ayant une session active dans les 5 dernières minutes",
|
||||
"online_sessions": "Sessions en ligne",
|
||||
"online_sessions_tooltip": "Sessions non révoquées actives dans les 5 dernières minutes — les utilisateurs multi-appareils en contribuent plusieurs",
|
||||
"section_accounts": "Comptes utilisateurs",
|
||||
"section_activity": "Activité en direct",
|
||||
"section_system": "Système",
|
||||
"live": "en direct",
|
||||
"live_tooltip": "Reflète les sessions actives dans les 5 dernières minutes",
|
||||
"version": "Version",
|
||||
"storage_overview": "Aperçu du stockage",
|
||||
"used": "Utilisé",
|
||||
@@ -1191,8 +1203,16 @@
|
||||
"gen_key_warning": "Conservez cette clé en toute sécurité. En cas de perte, les données cryptées sont irrémédiablement perdues.",
|
||||
"jobs": {
|
||||
"run_all_consistency": "Exécuter tous les contrôles de cohérence",
|
||||
"run_deep": "Exécuter en profondeur",
|
||||
"run_deep": "Analyse approfondie",
|
||||
"run_deep_hint": "Exécute également des variantes lentes (re-hachage de blob, détection bitrot).",
|
||||
"run_repair": "Réparer les compteurs",
|
||||
"run_repair_hint": "Corrige les compteurs de références (blobs + manifestes) désynchronisés détectés par l'audit. Sûr pour les données — seuls les compteurs changent, pas le contenu.",
|
||||
"run_repair_confirm_title": "Réparer les compteurs de références ?",
|
||||
"run_repair_confirm_body": "Lance l'audit sur chaque blob et manifeste, puis applique un UPDATE correctif à chaque compteur qui ne correspond pas au nombre réel de références. Sans risque pour les données : seuls les compteurs changent ; le contenu des blobs et les enregistrements de fichiers ne sont pas touchés. Vous pouvez exécuter cela à tout moment ; un passage en lecture seule s'exécute d'abord pour visualiser l'écart avant que la réparation ne l'écrase.",
|
||||
"run_repair_confirm_body_scoped": "Lance {{name}} et applique un UPDATE correctif à chaque compteur qui ne correspond pas au nombre réel de références. Sans risque pour les données : seuls les compteurs changent ; le contenu et les enregistrements de fichiers ne sont pas touchés.",
|
||||
"run_variants_menu": "Menu des variantes d'exécution",
|
||||
"run_repair_confirm": "Réparer",
|
||||
"triggered_ok_repair": "{{name}} : {{n}} compteur(s) réparé(s)",
|
||||
"col_name": "Nom",
|
||||
"col_cadence": "Fréquence",
|
||||
"col_last_run": "Dernière exécution",
|
||||
@@ -1202,20 +1222,20 @@
|
||||
"col_started_at": "Commencé",
|
||||
"col_status": "Statut",
|
||||
"col_duration": "Durée",
|
||||
"col_scanned": "Numérisé",
|
||||
"col_scanned": "Analysé",
|
||||
"col_progress": "Progrès",
|
||||
"col_findings": "Résultats",
|
||||
"progress_scanned_only": "{{n}} scanné",
|
||||
"progress_scanned_only_tooltip": "Aucun total disponible pour cette exécution (déploiement préalable de la barre de progression ou le locataire ne signale pas de sujet dénombrable).",
|
||||
"findings_present_tooltip": "Développez cette analyse pour voir les détails par résultat.",
|
||||
"col_error": "Erreur",
|
||||
"col_kind": "Genre",
|
||||
"col_kind": "Type",
|
||||
"col_severity": "Gravité",
|
||||
"col_resource": "Ressource",
|
||||
"col_detail": "Détail",
|
||||
"run": "Exécuter",
|
||||
"cancel": "Annuler",
|
||||
"refresh": "Rafraîchir",
|
||||
"refresh": "Actualiser",
|
||||
"runs_title": "Exécutions récentes",
|
||||
"run_json": "Résumé de l'exécution (JSON)",
|
||||
"findings_title": "Résultats",
|
||||
@@ -1225,22 +1245,22 @@
|
||||
"every_h": "toutes les {{n}} h",
|
||||
"every_min": "toutes les {{n}} minutes",
|
||||
"every_sec": "toutes les {{n}} s",
|
||||
"outcome_ok": "d'accord",
|
||||
"outcome_err": "se tromper",
|
||||
"outcome_ok": "ok",
|
||||
"outcome_err": "err",
|
||||
"outcome_issues": "problèmes",
|
||||
"outcome_notices": "avis",
|
||||
"n_findings": "{{n}} conclusions",
|
||||
"n_findings": "{{n}} résultats",
|
||||
"n_notices": "{{n}} remarques",
|
||||
"notices_present_tooltip": "Résultats informatifs – aucune action requise. Développez pour plus de détails.",
|
||||
"purge": "Purger les anciennes exécutions",
|
||||
"purge_hint": "Supprimez l’historique des exécutions terminées et échouées plus ancienne que la fenêtre de conservation choisie. Les résultats tombent avec leurs exécutions parentes. Les parcours non terminaux sont toujours préservés.",
|
||||
"purge_hint": "Supprimer l’historique des exécutions terminées et échouées plus ancien que la fenêtre de conservation choisie. Les résultats sont supprimés avec leurs exécutions parentes. Les exécutions non terminales sont toujours préservées.",
|
||||
"purge_body": "Supprimez l’historique des exécutions terminées et échouées plus ancienne que le nombre de jours choisi. Les résultats tombent avec leurs exécutions parentes. Les exécutions non terminales (en cours d’exécution, en pause, demandées en annulation) sont toujours conservées.",
|
||||
"purge_days_label": "Rétention (jours)",
|
||||
"purge_confirm": "Purger",
|
||||
"purge_done": "{{n}} anciennes exécutions purgées (rétention {{days}} jours)",
|
||||
"state_running": "en cours d'exécution",
|
||||
"never": "jamais",
|
||||
"just_now": "tout à l' heure",
|
||||
"just_now": "à l'instant",
|
||||
"n_min_ago": "il y a {{n}} min",
|
||||
"n_h_ago": "il y a {{n}} h",
|
||||
"n_d_ago": "il y a {{n}} j",
|
||||
@@ -1277,6 +1297,10 @@
|
||||
"revoked": "révoquée",
|
||||
"expired": "expirée",
|
||||
"active": "actif",
|
||||
"online": "en ligne",
|
||||
"idle": "inactif",
|
||||
"presence_online_tooltip": "En ligne — vue {{ago}}",
|
||||
"presence_idle_tooltip": "Inactif — vue {{ago}}",
|
||||
"revoke": "Révoquer",
|
||||
"empty": "Aucune session ne correspond au filtre actuel.",
|
||||
"revoke_self_confirm": "⚠️ Il s'agit de VOTRE session actuelle. La révoquer vous déconnectera immédiatement et vous devrez vous reconnecter. Continuer ?",
|
||||
|
||||
@@ -1183,11 +1183,11 @@
|
||||
"gen_key_warning": "इस कुंजी को सुरक्षित रूप से संग्रहित करें. यदि यह खो जाता है, तो एन्क्रिप्टेड डेटा अपरिवर्तनीय रूप से खो जाता है।",
|
||||
"jobs": {
|
||||
"run_all_consistency": "सभी संगतता जांचें चलाएँ",
|
||||
"run_deep": "गहरा रिश्ता",
|
||||
"run_deep": "गहन जाँच",
|
||||
"run_deep_hint": "धीमे वेरिएंट (ब्लॉब री-हैश, बिट्रोट डिटेक्शन) भी चलाता है।",
|
||||
"col_name": "नाम",
|
||||
"col_cadence": "ताल",
|
||||
"col_last_run": "आखरी बार",
|
||||
"col_last_run": "अंतिम रन",
|
||||
"col_outcome": "नतीजा",
|
||||
"col_state": "राज्य",
|
||||
"col_actions": "कार्रवाई",
|
||||
@@ -1201,11 +1201,11 @@
|
||||
"progress_scanned_only_tooltip": "इस रन के लिए कोई कुल उपलब्ध नहीं है (पूर्व-प्रगति-बार परिनियोजन या किरायेदार एक गणनीय विषय की रिपोर्ट नहीं करता है)।",
|
||||
"findings_present_tooltip": "प्रति-खोज विवरण देखने के लिए इस रन का विस्तार करें।",
|
||||
"col_error": "गलती",
|
||||
"col_kind": "दयालु",
|
||||
"col_kind": "प्रकार",
|
||||
"col_severity": "गंभीरता",
|
||||
"col_resource": "संसाधन",
|
||||
"col_detail": "विवरण",
|
||||
"run": "दौड़ना",
|
||||
"run": "चलाएँ",
|
||||
"cancel": "रद्द करना",
|
||||
"refresh": "ताज़ा करना",
|
||||
"runs_title": "हालिया रन",
|
||||
@@ -1218,10 +1218,10 @@
|
||||
"every_min": "हर {{n}} मिनट",
|
||||
"every_sec": "हर {{n}} एस",
|
||||
"outcome_ok": "ठीक है",
|
||||
"outcome_err": "ग़लती होना",
|
||||
"outcome_err": "त्रुटि",
|
||||
"outcome_issues": "समस्याएँ",
|
||||
"outcome_notices": "नोटिस",
|
||||
"n_findings": "{{n}}निष्कर्ष",
|
||||
"n_findings": "{{n}} निष्कर्ष",
|
||||
"n_notices": "{{n}}नोटिस",
|
||||
"notices_present_tooltip": "सूचनात्मक निष्कर्ष - किसी कार्रवाई की आवश्यकता नहीं। विवरण के लिए विस्तार करें.",
|
||||
"purge": "पुराने रन शुद्ध करें",
|
||||
|
||||
@@ -1183,11 +1183,11 @@
|
||||
"gen_key_warning": "Conserva questa chiave in modo sicuro. In caso di smarrimento, i dati crittografati andranno persi irrimediabilmente.",
|
||||
"jobs": {
|
||||
"run_all_consistency": "Esegui tutti i controlli di coerenza",
|
||||
"run_deep": "Corri in profondità",
|
||||
"run_deep": "Analisi approfondita",
|
||||
"run_deep_hint": "Esegue anche varianti lente (re-hash blob, rilevamento bitrot).",
|
||||
"col_name": "Nome",
|
||||
"col_cadence": "Cadenza",
|
||||
"col_last_run": "Ultima corsa",
|
||||
"col_last_run": "Ultima esecuzione",
|
||||
"col_outcome": "Risultato",
|
||||
"col_state": "Stato",
|
||||
"col_actions": "Azioni",
|
||||
@@ -1205,20 +1205,20 @@
|
||||
"col_severity": "Gravità",
|
||||
"col_resource": "Risorsa",
|
||||
"col_detail": "Dettaglio",
|
||||
"run": "Correre",
|
||||
"run": "Esegui",
|
||||
"cancel": "Cancellare",
|
||||
"refresh": "Aggiorna",
|
||||
"runs_title": "Esecuzioni recenti",
|
||||
"run_json": "Riepilogo esecuzione (JSON)",
|
||||
"findings_title": "Risultati",
|
||||
"no_runs": "Nessuna corsa ancora.",
|
||||
"no_runs": "Nessuna esecuzione ancora.",
|
||||
"no_findings": "Nessun risultato: analisi pulita.",
|
||||
"on_demand": "su richiesta",
|
||||
"every_h": "ogni {{n}} h",
|
||||
"every_min": "ogni {{n}} min",
|
||||
"every_sec": "ogni {{n}} s",
|
||||
"outcome_ok": "OK",
|
||||
"outcome_err": "errare",
|
||||
"outcome_err": "err",
|
||||
"outcome_issues": "problemi",
|
||||
"outcome_notices": "avvisi",
|
||||
"n_findings": "{{n}} risultati",
|
||||
|
||||
@@ -1183,7 +1183,7 @@
|
||||
"gen_key_warning": "このキーは安全に保管してください。紛失すると、暗号化されたデータは回復不能に失われます。",
|
||||
"jobs": {
|
||||
"run_all_consistency": "すべての整合性チェックを実行する",
|
||||
"run_deep": "深く走る",
|
||||
"run_deep": "詳細スキャン",
|
||||
"run_deep_hint": "低速な亜種 (BLOB 再ハッシュ、ビットロット検出) も実行します。",
|
||||
"col_name": "名前",
|
||||
"col_cadence": "ケイデンス",
|
||||
@@ -1201,14 +1201,14 @@
|
||||
"progress_scanned_only_tooltip": "この実行で利用できる合計はありません (進行状況バーのデプロイ前、またはテナントがカウント可能な件名を報告しない)。",
|
||||
"findings_present_tooltip": "この実行を展開すると、結果ごとの詳細が表示されます。",
|
||||
"col_error": "エラー",
|
||||
"col_kind": "親切",
|
||||
"col_kind": "種類",
|
||||
"col_severity": "重大度",
|
||||
"col_resource": "リソース",
|
||||
"col_detail": "詳細",
|
||||
"run": "走る",
|
||||
"run": "実行",
|
||||
"cancel": "キャンセル",
|
||||
"refresh": "リフレッシュ",
|
||||
"runs_title": "最近のランニング",
|
||||
"runs_title": "最近の実行",
|
||||
"run_json": "実行概要(JSON)",
|
||||
"findings_title": "調査結果",
|
||||
"no_runs": "まだ実行はありません。",
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"every_h": "{{n}} 時間ごと",
|
||||
"every_min": "{{n}} 分ごと",
|
||||
"every_sec": "{{n}} 秒ごと",
|
||||
"outcome_ok": "わかりました",
|
||||
"outcome_ok": "OK",
|
||||
"outcome_err": "エラー",
|
||||
"outcome_issues": "問題",
|
||||
"outcome_notices": "通知",
|
||||
|
||||
@@ -1218,14 +1218,14 @@
|
||||
"storage_backend_audit": "백엔드 일관성",
|
||||
"jobs": {
|
||||
"run_all_consistency": "모든 일관성 검사 실행",
|
||||
"run_deep": "깊이 달리다",
|
||||
"run_deep": "심층 스캔",
|
||||
"run_deep_hint": "또한 느린 변형(블롭 재해시, 비트롯 감지)을 실행합니다.",
|
||||
"col_name": "이름",
|
||||
"col_cadence": "운율",
|
||||
"col_last_run": "마지막 실행",
|
||||
"col_outcome": "결과",
|
||||
"col_state": "상태",
|
||||
"col_actions": "행위",
|
||||
"col_actions": "작업",
|
||||
"col_started_at": "시작됨",
|
||||
"col_status": "상태",
|
||||
"col_duration": "지속",
|
||||
@@ -1235,11 +1235,11 @@
|
||||
"progress_scanned_only_tooltip": "이 실행에 사용할 수 있는 총계가 없습니다(사전 진행률 표시줄 배포 또는 테넌트가 셀 수 있는 주제를 보고하지 않음).",
|
||||
"findings_present_tooltip": "이 실행을 확장하면 발견 항목별 세부 정보를 볼 수 있습니다.",
|
||||
"col_error": "오류",
|
||||
"col_kind": "친절한",
|
||||
"col_kind": "종류",
|
||||
"col_severity": "심각성",
|
||||
"col_resource": "의지",
|
||||
"col_detail": "세부 사항",
|
||||
"run": "달리다",
|
||||
"run": "실행",
|
||||
"cancel": "취소",
|
||||
"refresh": "새로 고치다",
|
||||
"runs_title": "최근 실행",
|
||||
@@ -1251,8 +1251,8 @@
|
||||
"every_h": "매 {{n}}시간마다",
|
||||
"every_min": "{{n}}분마다",
|
||||
"every_sec": "{{n}}초마다",
|
||||
"outcome_ok": "좋아요",
|
||||
"outcome_err": "실수",
|
||||
"outcome_ok": "OK",
|
||||
"outcome_err": "오류",
|
||||
"outcome_issues": "문제",
|
||||
"outcome_notices": "공지사항",
|
||||
"n_findings": "{{n}} 조사 결과",
|
||||
|
||||
@@ -1183,7 +1183,7 @@
|
||||
"gen_key_warning": "Bewaar deze sleutel veilig. Als het verloren gaat, zijn de gecodeerde gegevens onherstelbaar verloren.",
|
||||
"jobs": {
|
||||
"run_all_consistency": "Voer alle consistentiecontroles uit",
|
||||
"run_deep": "Ren diep",
|
||||
"run_deep": "Diepe scan",
|
||||
"run_deep_hint": "Voert ook langzame varianten uit (blob re-hash, bitrot-detectie).",
|
||||
"col_name": "Naam",
|
||||
"col_cadence": "Cadans",
|
||||
@@ -1201,11 +1201,11 @@
|
||||
"progress_scanned_only_tooltip": "Er is geen totaal beschikbaar voor deze run (implementatie vóór de voortgangsbalk of de tenant rapporteert geen telbaar onderwerp).",
|
||||
"findings_present_tooltip": "Vouw deze run uit om de details per vondst te bekijken.",
|
||||
"col_error": "Fout",
|
||||
"col_kind": "Vriendelijk",
|
||||
"col_kind": "Soort",
|
||||
"col_severity": "Ernst",
|
||||
"col_resource": "Bron",
|
||||
"col_detail": "Detail",
|
||||
"run": "Loop",
|
||||
"run": "Uitvoeren",
|
||||
"cancel": "Annuleren",
|
||||
"refresh": "Vernieuwen",
|
||||
"runs_title": "Recente runs",
|
||||
|
||||
@@ -1183,11 +1183,11 @@
|
||||
"gen_key_warning": "Przechowuj ten klucz w bezpiecznym miejscu. W przypadku jego utraty zaszyfrowane dane zostaną utracone bezpowrotnie.",
|
||||
"jobs": {
|
||||
"run_all_consistency": "Uruchom wszystkie kontrole spójności",
|
||||
"run_deep": "Biegnij głęboko",
|
||||
"run_deep": "Głęboka analiza",
|
||||
"run_deep_hint": "Uruchamia również powolne warianty (ponowne mieszanie obiektów blob, wykrywanie bitrot).",
|
||||
"col_name": "Nazwa",
|
||||
"col_cadence": "Rytm",
|
||||
"col_last_run": "Ostatni bieg",
|
||||
"col_last_run": "Ostatnie uruchomienie",
|
||||
"col_outcome": "Wynik",
|
||||
"col_state": "Państwo",
|
||||
"col_actions": "Działania",
|
||||
@@ -1201,24 +1201,24 @@
|
||||
"progress_scanned_only_tooltip": "Brak sumy dostępnej dla tego przebiegu (wdrożenie przed paskiem postępu lub dzierżawca nie zgłasza przedmiotu, który można policzyć).",
|
||||
"findings_present_tooltip": "Rozwiń ten przebieg, aby zobaczyć szczegóły dotyczące każdego znaleziska.",
|
||||
"col_error": "Błąd",
|
||||
"col_kind": "Uprzejmy",
|
||||
"col_kind": "Rodzaj",
|
||||
"col_severity": "Powaga",
|
||||
"col_resource": "Ratunek",
|
||||
"col_detail": "Szczegół",
|
||||
"run": "Uruchomić",
|
||||
"cancel": "Anulować",
|
||||
"refresh": "Odświeżać",
|
||||
"runs_title": "Ostatnie biegi",
|
||||
"runs_title": "Ostatnie uruchomienia",
|
||||
"run_json": "Podsumowanie uruchomienia (JSON)",
|
||||
"findings_title": "Ustalenia",
|
||||
"no_runs": "Nie ma jeszcze żadnych biegów.",
|
||||
"no_runs": "Nie ma jeszcze żadnych uruchomień.",
|
||||
"no_findings": "Brak wyników – czysty przebieg.",
|
||||
"on_demand": "na żądanie",
|
||||
"every_h": "co {{n}} godz",
|
||||
"every_min": "co {{n}} min",
|
||||
"every_sec": "co {{n}} s",
|
||||
"outcome_ok": "OK",
|
||||
"outcome_err": "błądzić",
|
||||
"outcome_err": "błąd",
|
||||
"outcome_issues": "kwestie",
|
||||
"outcome_notices": "uwagi",
|
||||
"n_findings": "{{n}} ustalenia",
|
||||
|
||||
@@ -1182,8 +1182,8 @@
|
||||
"gen_key": "Gerar chave",
|
||||
"gen_key_warning": "Armazene esta chave com segurança. Se for perdido, os dados criptografados serão perdidos irrecuperavelmente.",
|
||||
"jobs": {
|
||||
"run_all_consistency": "Execute todas as verificações de consistência",
|
||||
"run_deep": "Corra fundo",
|
||||
"run_all_consistency": "Executar todas as verificações de consistência",
|
||||
"run_deep": "Análise profunda",
|
||||
"run_deep_hint": "Também executa variantes lentas (re-hash de blob, detecção de bitrot).",
|
||||
"col_name": "Nome",
|
||||
"col_cadence": "Cadência",
|
||||
@@ -1205,20 +1205,20 @@
|
||||
"col_severity": "Gravidade",
|
||||
"col_resource": "Recurso",
|
||||
"col_detail": "Detalhe",
|
||||
"run": "Correr",
|
||||
"run": "Executar",
|
||||
"cancel": "Cancelar",
|
||||
"refresh": "Atualizar",
|
||||
"runs_title": "Execuções recentes",
|
||||
"run_json": "Resumo da execução (JSON)",
|
||||
"findings_title": "Descobertas",
|
||||
"no_runs": "Ainda não há corridas.",
|
||||
"no_runs": "Ainda não há execuções.",
|
||||
"no_findings": "Nenhuma descoberta – execução limpa.",
|
||||
"on_demand": "Sob demanda",
|
||||
"every_h": "a cada {{n}} h",
|
||||
"every_min": "a cada {{n}}min",
|
||||
"every_sec": "cada {{n}} s",
|
||||
"outcome_ok": "OK",
|
||||
"outcome_err": "errar",
|
||||
"outcome_err": "err",
|
||||
"outcome_issues": "problemas",
|
||||
"outcome_notices": "avisos",
|
||||
"n_findings": "{{n}} descobertas",
|
||||
|
||||
@@ -1182,8 +1182,8 @@
|
||||
"gen_key": "Сгенерировать ключ",
|
||||
"gen_key_warning": "Храните этот ключ в надежном месте. Если он утерян, зашифрованные данные теряются безвозвратно.",
|
||||
"jobs": {
|
||||
"run_all_consistency": "Запустите все проверки согласованности",
|
||||
"run_deep": "Беги глубоко",
|
||||
"run_all_consistency": "Запустить все проверки согласованности",
|
||||
"run_deep": "Глубокая проверка",
|
||||
"run_deep_hint": "Также выполняются медленные варианты (повторное хэширование больших двоичных объектов, обнаружение битротов).",
|
||||
"col_name": "Имя",
|
||||
"col_cadence": "Каденс",
|
||||
@@ -1201,27 +1201,27 @@
|
||||
"progress_scanned_only_tooltip": "Для этого запуска общая сумма недоступна (развертывание до индикатора выполнения или клиент не сообщает об подсчитываемой теме).",
|
||||
"findings_present_tooltip": "Разверните этот прогон, чтобы просмотреть детали каждого результата.",
|
||||
"col_error": "Ошибка",
|
||||
"col_kind": "Добрый",
|
||||
"col_kind": "Тип",
|
||||
"col_severity": "Серьезность",
|
||||
"col_resource": "Ресурс",
|
||||
"col_detail": "Деталь",
|
||||
"run": "Бегать",
|
||||
"run": "Запустить",
|
||||
"cancel": "Отмена",
|
||||
"refresh": "Обновить",
|
||||
"runs_title": "Недавние запуски",
|
||||
"run_json": "Сводка выполнения (JSON)",
|
||||
"findings_title": "Выводы",
|
||||
"no_runs": "Пробегов пока нет.",
|
||||
"no_runs": "Запусков пока нет.",
|
||||
"no_findings": "Никаких результатов — чистый пробег.",
|
||||
"on_demand": "по требованию",
|
||||
"every_h": "каждые {{n}} ч",
|
||||
"every_min": "каждые {{n}} мин.",
|
||||
"every_sec": "каждые {{n}} с",
|
||||
"outcome_ok": "хорошо",
|
||||
"outcome_err": "ошибаться",
|
||||
"outcome_ok": "ок",
|
||||
"outcome_err": "ош",
|
||||
"outcome_issues": "проблемы",
|
||||
"outcome_notices": "уведомления",
|
||||
"n_findings": "{{n}} выводы",
|
||||
"n_findings": "{{n}} результатов",
|
||||
"n_notices": "{{n}} уведомления",
|
||||
"notices_present_tooltip": "Информационные выводы — никаких действий не требуется. Разверните для подробностей.",
|
||||
"purge": "Очистка старых пробегов",
|
||||
|
||||
@@ -1165,7 +1165,7 @@
|
||||
"gen_key_warning": "安全地保存此密鑰。如果遺失,加密資料將無法恢復。",
|
||||
"jobs": {
|
||||
"run_all_consistency": "執行所有一致性檢查",
|
||||
"run_deep": "深入運行",
|
||||
"run_deep": "深度掃描",
|
||||
"run_deep_hint": "也運行緩慢的變體(blob 重新哈希、bitrot 檢測)。",
|
||||
"col_name": "姓名",
|
||||
"col_cadence": "節奏",
|
||||
@@ -1187,10 +1187,10 @@
|
||||
"col_severity": "嚴重性",
|
||||
"col_resource": "資源",
|
||||
"col_detail": "細節",
|
||||
"run": "跑步",
|
||||
"run": "運行",
|
||||
"cancel": "取消",
|
||||
"refresh": "重新整理",
|
||||
"runs_title": "最近的跑步",
|
||||
"runs_title": "最近的運行",
|
||||
"run_json": "運行摘要 (JSON)",
|
||||
"findings_title": "發現",
|
||||
"no_runs": "還沒有運行。",
|
||||
@@ -1200,7 +1200,7 @@
|
||||
"every_min": "每 {{n}} 分鐘",
|
||||
"every_sec": "每{{n}}秒",
|
||||
"outcome_ok": "好的",
|
||||
"outcome_err": "犯錯",
|
||||
"outcome_err": "錯誤",
|
||||
"outcome_issues": "問題",
|
||||
"outcome_notices": "通知",
|
||||
"n_findings": "{{n}} 研究結果",
|
||||
|
||||
@@ -1165,7 +1165,7 @@
|
||||
"gen_key_warning": "安全地保存此密钥。如果丢失,加密数据将无法恢复。",
|
||||
"jobs": {
|
||||
"run_all_consistency": "运行所有一致性检查",
|
||||
"run_deep": "深入运行",
|
||||
"run_deep": "深度扫描",
|
||||
"run_deep_hint": "还运行缓慢的变体(blob 重新哈希、bitrot 检测)。",
|
||||
"col_name": "姓名",
|
||||
"col_cadence": "节奏",
|
||||
@@ -1187,10 +1187,10 @@
|
||||
"col_severity": "严重性",
|
||||
"col_resource": "资源",
|
||||
"col_detail": "细节",
|
||||
"run": "跑步",
|
||||
"run": "运行",
|
||||
"cancel": "取消",
|
||||
"refresh": "刷新",
|
||||
"runs_title": "最近的跑步",
|
||||
"runs_title": "最近的运行",
|
||||
"run_json": "运行摘要 (JSON)",
|
||||
"findings_title": "发现",
|
||||
"no_runs": "还没有运行。",
|
||||
@@ -1200,7 +1200,7 @@
|
||||
"every_min": "每 {{n}} 分钟",
|
||||
"every_sec": "每{{n}}秒",
|
||||
"outcome_ok": "好的",
|
||||
"outcome_err": "犯错",
|
||||
"outcome_err": "错误",
|
||||
"outcome_issues": "问题",
|
||||
"outcome_notices": "通知",
|
||||
"n_findings": "{{n}} 研究结果",
|
||||
|
||||
Reference in New Issue
Block a user