SECURITY - LAUNCHER: harden storage and access lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-05-12 12:50:52 +03:00
parent 2b34cf9f1b
commit b3915a851c
12 changed files with 459 additions and 4326 deletions
+43 -15
View File
@@ -108,15 +108,19 @@ export function LauncherApp() {
const runtimeDataRef = useRef(data);
const runtimeProfileIdRef = useRef(activeProfileId);
const runtimeClientIdRef = useRef(activeClientId);
const resolvedProfileId = useMemo(
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
[activeProfileId, authSession, data]
);
useEffect(() => {
runtimeDataRef.current = data;
runtimeProfileIdRef.current = activeProfileId;
runtimeProfileIdRef.current = resolvedProfileId;
runtimeClientIdRef.current = activeClientId;
}, [activeClientId, activeProfileId, data]);
}, [activeClientId, data, resolvedProfileId]);
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
const me = useMemo(() => buildMe(data, resolvedProfileId, activeClientId), [data, resolvedProfileId, activeClientId]);
const activeProfileUser = data.users.find((user) => user.id === resolvedProfileId) ?? data.users[0];
const currentAccessRequest = useMemo(() => {
if (!authSession?.authenticated || !authSession.user.email) return null;
@@ -150,10 +154,10 @@ export function LauncherApp() {
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
const launcherServices = useMemo(
() => {
const services = buildLauncherServices(data, activeProfileId, resolvedClientId);
const services = buildLauncherServices(data, resolvedProfileId, resolvedClientId);
if (!authSession?.authenticated || authApps === null) {
return services;
return [];
}
return services.map((service) => {
@@ -167,32 +171,34 @@ export function LauncherApp() {
effectiveAccess: {
...service.effectiveAccess,
allowed: false,
visible: true,
visible: false,
openEnabled: false,
reason: "Нет доступа",
},
};
}
const openEnabled = app.hasAccess && app.status === "active";
const appVisible = app.hasAccess && app.status !== "hidden" && app.status !== "disabled";
const allowed = appVisible && service.effectiveAccess.allowed;
const openEnabled = appVisible && app.status === "active" && service.effectiveAccess.openEnabled;
return {
...service,
title: app.title || service.title,
description: app.description || service.description,
openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null,
userAccess: openEnabled ? ("allowed" as const) : ("denied" as const),
userAccess: allowed ? ("allowed" as const) : ("denied" as const),
effectiveAccess: {
...service.effectiveAccess,
allowed: app.hasAccess,
visible: true,
allowed,
visible: appVisible && service.effectiveAccess.visible,
openEnabled,
reason: app.accessReason || (app.hasAccess ? "Доступ подтверждён" : "Нет доступа"),
reason: !app.hasAccess ? app.accessReason || "Нет доступа" : service.effectiveAccess.reason,
},
};
});
}).filter((service) => service.effectiveAccess.visible);
},
[authApps, authAppsBySlug, authSession, data, activeProfileId, resolvedClientId]
[authApps, authAppsBySlug, authSession, data, resolvedProfileId, resolvedClientId]
);
useEffect(() => {
@@ -846,7 +852,7 @@ export function LauncherApp() {
me={runtimeMe}
clients={data.clients}
profileOptions={profileOptions}
activeProfileId={activeProfileId}
activeProfileId={resolvedProfileId}
activeClientId={resolvedClientId}
adminOpen={adminOpen}
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
@@ -1191,6 +1197,28 @@ function resolveAuthenticatedContext(
};
}
function resolveRuntimeProfileId(data: LauncherData, session: AuthSession | null, currentProfileId: string): string {
if (data.users.some((user) => user.id === currentProfileId)) {
return currentProfileId;
}
if (session?.authenticated) {
const sessionEmail = session.user.email?.toLowerCase();
const sessionSub = session.user.sub;
const sessionUser = data.users.find(
(user) =>
(sessionSub && user.authentikUserId === sessionSub) ||
(sessionEmail && user.email.toLowerCase() === sessionEmail)
);
if (sessionUser) {
return sessionUser.id;
}
}
return data.users[0]?.id ?? currentProfileId;
}
function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string {
const user = data.users.find((item) => item.id === userId);
const isRoot = user?.id === "user_root";
+1 -1
View File
@@ -45,7 +45,7 @@ export interface LauncherAuthApp {
export async function fetchAuthSession(): Promise<AuthSession> {
const response = await fetch("/api/me", { cache: "no-store" });
if (response.status === 401) {
if (response.status === 401 || response.status === 403) {
return (await response.json()) as UnauthenticatedSession;
}
+3 -1
View File
@@ -261,7 +261,9 @@ export function buildMe(data: LauncherData, userId: string, requestedClientId?:
}));
const fallbackClientId =
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ?? availableMemberships[0]?.clientId;
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ??
availableMemberships[0]?.clientId ??
PUBLIC_POOL_CLIENT.id;
const canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId);
const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId);
+4 -2
View File
@@ -30,11 +30,13 @@ export async function uploadStorageFile(file: File): Promise<StoredFileResponse>
export async function loadPersistedLauncherData(): Promise<LauncherData | null> {
try {
const response = await fetch(`/storage/launcher-data.json?ts=${Date.now()}`, { cache: "no-store" });
const response = await fetch("/api/storage/data", { cache: "no-store" });
if (!response.ok) return null;
return normalizeLauncherData((await response.json()) as Partial<LauncherData>);
const data = normalizeLauncherData((await response.json()) as Partial<LauncherData>);
return data.users.length > 0 ? data : null;
} catch {
return null;
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { KeyRound, Save, Upload, X } from "lucide-react";
import type { LauncherUser } from "../../entities/user/types";
import { uploadStorageFile } from "../../shared/api/storageApi";
@@ -17,13 +17,42 @@ export function ProfileSettingsPanel({
onChangePassword: (newPassword: string) => Promise<void>;
}) {
const [draft, setDraft] = useState<LauncherUser>(user);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(user.avatarUrl ?? null);
const [newPassword, setNewPassword] = useState("");
const [uploading, setUploading] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const draftRef = useRef(draft);
const hasUnsavedProfileChangesRef = useRef(false);
const lastSyncedUserIdRef = useRef(user.id);
useEffect(() => setDraft(user), [user]);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
useEffect(() => {
const isAnotherUser = lastSyncedUserIdRef.current !== user.id;
if (!isAnotherUser && hasUnsavedProfileChangesRef.current) {
return;
}
lastSyncedUserIdRef.current = user.id;
hasUnsavedProfileChangesRef.current = false;
setDraft(user);
setAvatarPreviewUrl(user.avatarUrl ?? null);
setUploading(false);
}, [user]);
useEffect(
() => () => {
if (avatarPreviewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(avatarPreviewUrl);
}
},
[avatarPreviewUrl]
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -35,12 +64,20 @@ export function ProfileSettingsPanel({
}, [onClose]);
function update<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
setDraft((current) => ({ ...current, [key]: value }));
hasUnsavedProfileChangesRef.current = true;
setDraft((current) => {
const nextDraft = { ...current, [key]: value };
draftRef.current = nextDraft;
return nextDraft;
});
}
async function handleAvatarUpload(file: File | undefined) {
if (!file) return;
const localPreviewUrl = URL.createObjectURL(file);
hasUnsavedProfileChangesRef.current = true;
setAvatarPreviewUrl(localPreviewUrl);
setUploading(true);
setMessage(null);
@@ -49,6 +86,7 @@ export function ProfileSettingsPanel({
update("avatarUrl", result.url);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар");
setAvatarPreviewUrl(draftRef.current.avatarUrl ?? null);
} finally {
setUploading(false);
}
@@ -59,13 +97,16 @@ export function ProfileSettingsPanel({
setMessage(null);
try {
const profileDraft = draftRef.current;
await onSaveProfile({
name: draft.name,
email: draft.email,
phone: draft.phone ?? null,
position: draft.position ?? null,
avatarUrl: draft.avatarUrl ?? null,
name: profileDraft.name,
email: profileDraft.email,
phone: profileDraft.phone ?? null,
position: profileDraft.position ?? null,
avatarUrl: profileDraft.avatarUrl ?? null,
});
hasUnsavedProfileChangesRef.current = false;
setAvatarPreviewUrl(profileDraft.avatarUrl ?? null);
setMessage("Профиль сохранён");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль");
@@ -109,8 +150,8 @@ export function ProfileSettingsPanel({
<div className="profile-settings-panel__body">
<div className="profile-settings-avatar-card">
{draft.avatarUrl ? (
<img className="profile-settings-avatar-card__image" src={draft.avatarUrl} alt="" />
{avatarPreviewUrl ? (
<img className="profile-settings-avatar-card__image" src={avatarPreviewUrl} alt="" />
) : (
<span className="profile-settings-avatar-card__image">{initials(draft.name)}</span>
)}
@@ -121,7 +162,11 @@ export function ProfileSettingsPanel({
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={uploading}
onChange={(event) => void handleAvatarUpload(event.target.files?.[0])}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
void handleAvatarUpload(file);
}}
/>
</label>
</div>