- components/plan/PlanChangeSheet.tsx — upgrade/downgrade briefing per pricing-tiers.md §4
(fetches GET /api/plan/change-preview; gains/keeps/changes; recovery-safety line;
billing hint w/o purchase button; CTA row, no 'are you sure?' interstitial)
- debug.tsx: PlanOverrideToggle routes every flip through PlanChangeSheet first
- devices.tsx + protectedDevices.ts: 'degraded' status (red, inline 'protection expired —
remove the profile yourself' hint, no green checkmark); maxProtectedDevices limit hint
- mail.tsx + MailAccountCard.tsx + useMailStatus.ts: over-limit banner + paused-account
greyed-out + PausedBadge (all defensive — only shows if backend sends the field)
- blocker.tsx: free-tier transparency hint ('Grundschutz aktiv — voller Schutz: Pro/Legend')
+ custom-domain over-limit banner
- locales: plan.change.* + plan_limit.* (de + en)
tsc clean. Backend side (GET /api/plan/change-preview, paused/degraded fields) in progress
in parallel — UI built defensively to work before it lands.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
79 lines
2.1 KiB
TypeScript
79 lines
2.1 KiB
TypeScript
import { create } from 'zustand';
|
|
import { apiFetch } from '../lib/api';
|
|
|
|
export type ProtectedDeviceStatus = 'pending' | 'active' | 'revoked' | 'degraded';
|
|
|
|
export interface ProtectedDevice {
|
|
id: string;
|
|
platform: 'mac' | 'windows' | string;
|
|
label: string;
|
|
status: ProtectedDeviceStatus;
|
|
installedAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface EnrollResult {
|
|
deviceId: string;
|
|
downloadUrl: string;
|
|
}
|
|
|
|
type ProtectedDevicesState = {
|
|
devices: ProtectedDevice[];
|
|
loading: boolean;
|
|
enrolling: boolean;
|
|
|
|
load: () => Promise<void>;
|
|
enroll: (label: string, platform: 'mac' | 'windows') => Promise<EnrollResult>;
|
|
confirmInstalled: (id: string) => Promise<void>;
|
|
remove: (id: string) => Promise<{ manualRemovalRequired: boolean }>;
|
|
};
|
|
|
|
export const useProtectedDevicesStore = create<ProtectedDevicesState>((set, get) => ({
|
|
devices: [],
|
|
loading: false,
|
|
enrolling: false,
|
|
|
|
load: async () => {
|
|
set({ loading: true });
|
|
try {
|
|
const devices = await apiFetch<ProtectedDevice[]>('/api/devices/protected');
|
|
set({ devices });
|
|
} catch {
|
|
// endpoint might not be ready yet — keep empty state, screen handles it
|
|
} finally {
|
|
set({ loading: false });
|
|
}
|
|
},
|
|
|
|
enroll: async (label: string, platform: 'mac' | 'windows') => {
|
|
set({ enrolling: true });
|
|
try {
|
|
const result = await apiFetch<EnrollResult>('/api/devices/enroll', {
|
|
method: 'POST',
|
|
body: { platform, label },
|
|
});
|
|
await get().load();
|
|
return result;
|
|
} finally {
|
|
set({ enrolling: false });
|
|
}
|
|
},
|
|
|
|
confirmInstalled: async (id: string) => {
|
|
await apiFetch(`/api/devices/${id}/confirm-installed`, { method: 'POST' });
|
|
set((s) => ({
|
|
devices: s.devices.map((d) =>
|
|
d.id === id ? { ...d, status: 'active' as const, installedAt: new Date().toISOString() } : d
|
|
),
|
|
}));
|
|
},
|
|
|
|
remove: async (id: string) => {
|
|
const res = await apiFetch<{ manualRemovalRequired: boolean }>(`/api/devices/${id}/revoke`, {
|
|
method: 'DELETE',
|
|
});
|
|
set((s) => ({ devices: s.devices.filter((d) => d.id !== id) }));
|
|
return res;
|
|
},
|
|
}));
|