ENH - compact admin workspace navigation

This commit is contained in:
Leon.Schmidt
2026-08-20 21:06:53 +02:00
parent 8e2523d555
commit a9b0c4e0f4
5 changed files with 422 additions and 32 deletions
@@ -59,6 +59,25 @@ describe('standalone admin panel contracts', () => {
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
})
it('uses a compact transparent shell with dedicated admin workspaces', () => {
expect(source).toContain('background: transparent')
expect(source).toContain('width: min(84vw, 1420px)')
expect(source).toContain('height: min(82vh, 820px)')
expect(source).not.toContain('backdrop-filter: blur(2px)')
for (const tab of [
'overview',
'players',
'devices',
'apps',
'security',
'audit',
]) {
expect(source).toContain(`selectTab('${tab}')`)
expect(source).toContain(`t('tabs.${tab}')`)
}
})
it('stages app changes locally and saves them only from the toolbar action', () => {
expect(source).toContain('const drafts = ref<')
expect(source).toContain('@click="saveChanges"')
+376 -25
View File
@@ -8,6 +8,7 @@ import {
Clipboard,
Database,
Eye,
Grid2X2,
HardDrive,
KeyRound,
LayoutDashboard,
@@ -45,17 +46,22 @@ import { copyText } from '@/utils/clipboard'
import { parseDatabaseDate } from '@/utils/date'
import { nuiCall } from '@/utils/nui'
type AdminTab = 'players' | 'audit'
type AdminTab =
| 'overview'
| 'players'
| 'devices'
| 'apps'
| 'security'
| 'audit'
type PendingAction =
| { kind: 'close' }
| { kind: 'player'; source: number }
| { kind: 'refresh' }
| { kind: 'tab'; tab: AdminTab }
const emit = defineEmits<{ close: [] }>()
const admin = useAdminStore()
const phone = usePhoneStore()
const tab = ref<AdminTab>('players')
const tab = ref<AdminTab>('overview')
const playerQuery = ref('')
const appQuery = ref('')
const selectedImei = ref('')
@@ -251,6 +257,10 @@ function queueAction(action: PendingAction): void {
discardDialog.value = true
}
function selectTab(nextTab: AdminTab): void {
tab.value = nextTab
}
async function runAction(action: PendingAction): Promise<void> {
if (action.kind === 'close') {
const response = await nuiCall('admin:close')
@@ -262,10 +272,6 @@ async function runAction(action: PendingAction): Promise<void> {
await refreshData()
return
}
if (action.kind === 'tab') {
tab.value = action.tab
return
}
await loadPlayer(action.source)
}
@@ -387,7 +393,7 @@ onBeforeUnmount(() => {
<div class="admin-panel-context">
<span class="admin-panel-context__path">
{{ tab === 'players' ? t('editor.players') : t('editor.audit') }}
{{ t('tabs.' + tab) }}
</span>
<ChevronRight :size="14" />
<strong>{{
@@ -435,21 +441,57 @@ onBeforeUnmount(() => {
<div class="admin-panel-body">
<nav class="admin-panel-rail" :aria-label="t('navigation')">
<button
type="button"
:class="{ 'is-active': tab === 'overview' }"
:aria-label="t('tabs.overview')"
:title="t('tabs.overview')"
@click="selectTab('overview')"
>
<LayoutDashboard :size="19" />
</button>
<button
type="button"
:class="{ 'is-active': tab === 'players' }"
:aria-label="t('tabs.players')"
:title="t('tabs.players')"
@click="queueAction({ kind: 'tab', tab: 'players' })"
@click="selectTab('players')"
>
<UsersRound :size="19" />
</button>
<button
type="button"
:class="{ 'is-active': tab === 'devices' }"
:aria-label="t('tabs.devices')"
:title="t('tabs.devices')"
@click="selectTab('devices')"
>
<Smartphone :size="19" />
</button>
<button
type="button"
:class="{ 'is-active': tab === 'apps' }"
:aria-label="t('tabs.apps')"
:title="t('tabs.apps')"
@click="selectTab('apps')"
>
<Grid2X2 :size="19" />
</button>
<button
type="button"
:class="{ 'is-active': tab === 'security' }"
:aria-label="t('tabs.security')"
:title="t('tabs.security')"
@click="selectTab('security')"
>
<KeyRound :size="19" />
</button>
<button
type="button"
:class="{ 'is-active': tab === 'audit' }"
:aria-label="t('tabs.audit')"
:title="t('tabs.audit')"
@click="queueAction({ kind: 'tab', tab: 'audit' })"
@click="selectTab('audit')"
>
<ScrollText :size="19" />
</button>
@@ -460,7 +502,59 @@ onBeforeUnmount(() => {
</nav>
<aside class="admin-panel-directory">
<template v-if="tab === 'players'">
<template v-if="tab === 'overview'">
<div class="admin-panel-directory__header">
<div>
<span>{{ t('overview.eyebrow') }}</span>
<h2>{{ t('overview.features') }}</h2>
</div>
<strong>{{ admin.stats.online }}</strong>
</div>
<div class="admin-panel-overview-directory">
<button type="button" @click="selectTab('players')">
<UsersRound :size="17" />
<span>
<strong>{{ t('tabs.players') }}</strong>
<small>{{ t('overview.playerFeature') }}</small>
</span>
<ChevronRight :size="14" />
</button>
<button type="button" @click="selectTab('devices')">
<Smartphone :size="17" />
<span>
<strong>{{ t('tabs.devices') }}</strong>
<small>{{ t('overview.deviceFeature') }}</small>
</span>
<ChevronRight :size="14" />
</button>
<button type="button" @click="selectTab('apps')">
<Grid2X2 :size="17" />
<span>
<strong>{{ t('tabs.apps') }}</strong>
<small>{{ t('overview.appFeature') }}</small>
</span>
<ChevronRight :size="14" />
</button>
<button type="button" @click="selectTab('security')">
<KeyRound :size="17" />
<span>
<strong>{{ t('tabs.security') }}</strong>
<small>{{ t('overview.securityFeature') }}</small>
</span>
<ChevronRight :size="14" />
</button>
<button type="button" @click="selectTab('audit')">
<ScrollText :size="17" />
<span>
<strong>{{ t('tabs.audit') }}</strong>
<small>{{ t('overview.auditFeature') }}</small>
</span>
<ChevronRight :size="14" />
</button>
</div>
</template>
<template v-else-if="tab !== 'audit'">
<div class="admin-panel-directory__header">
<div>
<span>{{ t('players.eyebrow') }}</span>
@@ -544,6 +638,123 @@ onBeforeUnmount(() => {
<span>{{ t('loading') }}</span>
</div>
<section
v-else-if="tab === 'overview'"
class="admin-panel-editor__scroll"
>
<div class="admin-panel-page-heading">
<div class="admin-panel-heading-icon">
<LayoutDashboard :size="23" />
</div>
<div>
<span>{{ t('overview.eyebrow') }}</span>
<h1>{{ t('overview.title') }}</h1>
<p>{{ t('overview.body') }}</p>
</div>
</div>
<div class="admin-panel-stat-grid">
<article>
<UsersRound :size="18" />
<span>{{ t('overview.online') }}</span>
<strong>{{ admin.stats.online }}</strong>
</article>
<article>
<Smartphone :size="18" />
<span>{{ t('overview.devices') }}</span>
<strong>{{ admin.stats.devices }}</strong>
</article>
<article>
<Database :size="18" />
<span>{{ t('overview.accounts') }}</span>
<strong>{{ admin.stats.accounts }}</strong>
</article>
<article>
<ScrollText :size="18" />
<span>{{ t('overview.audit') }}</span>
<strong>{{ admin.audit.length }}</strong>
</article>
</div>
<article class="admin-panel-section-card">
<div class="admin-panel-section-card__heading">
<div>
<span>{{ t('overview.control') }}</span>
<h2>{{ t('overview.features') }}</h2>
<p>{{ t('overview.featuresBody') }}</p>
</div>
<ShieldCheck :size="20" />
</div>
<div class="admin-panel-feature-grid">
<button type="button" @click="selectTab('players')">
<UsersRound :size="20" />
<span>
<strong>{{ t('tabs.players') }}</strong>
<small>{{ t('overview.playerFeature') }}</small>
</span>
<ChevronRight :size="15" />
</button>
<button type="button" @click="selectTab('devices')">
<Smartphone :size="20" />
<span>
<strong>{{ t('tabs.devices') }}</strong>
<small>{{ t('overview.deviceFeature') }}</small>
</span>
<ChevronRight :size="15" />
</button>
<button type="button" @click="selectTab('apps')">
<Grid2X2 :size="20" />
<span>
<strong>{{ t('tabs.apps') }}</strong>
<small>{{ t('overview.appFeature') }}</small>
</span>
<ChevronRight :size="15" />
</button>
<button type="button" @click="selectTab('security')">
<KeyRound :size="20" />
<span>
<strong>{{ t('tabs.security') }}</strong>
<small>{{ t('overview.securityFeature') }}</small>
</span>
<ChevronRight :size="15" />
</button>
</div>
</article>
<article class="admin-panel-section-card">
<div class="admin-panel-section-card__heading">
<div>
<span>{{ t('audit.eyebrow') }}</span>
<h2>{{ t('overview.recent') }}</h2>
</div>
<ScrollText :size="20" />
</div>
<div v-if="admin.audit.length" class="admin-panel-audit-grid">
<article
v-for="entry in admin.audit.slice(0, 4)"
:key="entry.id"
>
<div class="admin-panel-audit-grid__topline">
<span>{{ t('audit.actions.' + entry.action) }}</span>
<time>{{ formatDate(entry.createdAt) }}</time>
</div>
<strong>{{ auditDescription(entry) }}</strong>
<p>
{{
t('audit.by', {
actor: entry.actorName,
target: String(entry.targetSource ?? '—'),
})
}}
</p>
</article>
</div>
<div v-else class="admin-panel-inline-empty">
{{ t('audit.emptyBody') }}
</div>
</article>
</section>
<section
v-else-if="tab === 'audit'"
class="admin-panel-editor__scroll"
@@ -602,6 +813,7 @@ onBeforeUnmount(() => {
</div>
<div
v-if="tab === 'devices' || tab === 'apps' || tab === 'security'"
class="admin-panel-device-tabs"
:aria-label="t('devices.choose')"
>
@@ -623,7 +835,7 @@ onBeforeUnmount(() => {
</button>
</div>
<div class="admin-panel-stat-grid">
<div v-if="tab === 'players'" class="admin-panel-stat-grid">
<article>
<WalletCards :size="18" />
<span>{{ t('detail.cash') }}</span>
@@ -663,7 +875,7 @@ onBeforeUnmount(() => {
</article>
</div>
<div class="admin-panel-section-grid">
<div v-if="tab === 'players'" class="admin-panel-section-grid">
<article class="admin-panel-section-card">
<div class="admin-panel-section-card__heading">
<div>
@@ -736,7 +948,64 @@ onBeforeUnmount(() => {
</article>
</div>
<article v-if="selectedDevice" class="admin-panel-section-card">
<article
v-if="tab === 'devices'"
class="admin-panel-section-card admin-panel-section-card--focused"
>
<div class="admin-panel-section-card__heading">
<div>
<span>{{ t('editor.device') }}</span>
<h2>{{ t('devices.title') }}</h2>
<p>{{ t('devices.body') }}</p>
</div>
<HardDrive :size="20" />
</div>
<div v-if="selectedDevice" class="admin-panel-device-summary">
<span class="admin-panel-device-summary__icon">
<Smartphone :size="23" />
</span>
<div>
<strong>{{ selectedDevice.name }}</strong>
<span>{{
selectedDevice.number || t('devices.noNumber')
}}</span>
</div>
<small>{{
selectedDevice.simType || t('devices.noSim')
}}</small>
</div>
<dl v-if="selectedDevice" class="admin-panel-field-list">
<div>
<dt>{{ t('devices.imei') }}</dt>
<dd>{{ selectedDevice.imei }}</dd>
</div>
<div>
<dt>{{ t('devices.updated') }}</dt>
<dd>{{ formatDate(selectedDevice.updatedAt) }}</dd>
</div>
<div>
<dt>{{ t('devices.apps') }}</dt>
<dd>{{ selectedDevice.apps.claimed.length }}</dd>
</div>
<div>
<dt>{{ t('devices.account') }}</dt>
<dd>
{{
selectedDevice.account?.email ||
t('credentials.noAccount')
}}
</dd>
</div>
</dl>
<div v-else class="admin-panel-inline-empty">
{{ t('devices.emptyBody') }}
</div>
</article>
<article
v-if="tab === 'security' && selectedDevice"
class="admin-panel-section-card admin-panel-section-card--focused"
>
<div class="admin-panel-section-card__heading">
<div>
<span>{{ t('editor.security') }}</span>
@@ -792,7 +1061,10 @@ onBeforeUnmount(() => {
</div>
</article>
<article v-if="selectedDevice" class="admin-panel-section-card">
<article
v-if="tab === 'apps' && selectedDevice"
class="admin-panel-section-card admin-panel-section-card--focused"
>
<div
class="admin-panel-section-card__heading admin-panel-app-heading"
>
@@ -866,6 +1138,15 @@ onBeforeUnmount(() => {
</button>
</div>
</article>
<article
v-if="(tab === 'apps' || tab === 'security') && !selectedDevice"
class="admin-panel-section-card admin-panel-section-card--focused"
>
<div class="admin-panel-inline-empty">
{{ t('devices.emptyBody') }}
</div>
</article>
</section>
<section v-else class="admin-panel-empty-editor">
@@ -968,25 +1249,22 @@ onBeforeUnmount(() => {
inset: 0;
display: grid;
place-items: center;
padding: 3.2vh 3vw;
padding: 2.5vh 2.5vw;
color: var(--admin-text);
background:
radial-gradient(circle at 50% 30%, rgba(28, 39, 29, 0.18), transparent 55%),
rgba(1, 3, 2, 0.56);
background: transparent;
font-family: var(--sky-font-family, Inter, sans-serif);
backdrop-filter: blur(2px);
pointer-events: auto;
}
.admin-panel-window {
width: min(94vw, 1600px);
height: min(91vh, 940px);
width: min(84vw, 1420px);
height: min(82vh, 820px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 8px;
background: var(--admin-bg);
box-shadow:
0 35px 110px rgba(0, 0, 0, 0.72),
0 24px 70px rgba(0, 0, 0, 0.68),
inset 0 1px rgba(255, 255, 255, 0.025);
}
@@ -1250,6 +1528,69 @@ button:disabled {
text-align: center;
}
.admin-panel-overview-directory {
height: calc(100% - 70px);
display: grid;
align-content: start;
gap: 6px;
overflow-y: auto;
padding: 0 10px 14px;
}
.admin-panel-overview-directory button,
.admin-panel-feature-grid button {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
min-height: 52px;
padding: 8px 10px;
border: 1px solid var(--admin-border);
border-radius: 5px;
color: var(--admin-muted);
background: #151715;
text-align: left;
cursor: pointer;
}
.admin-panel-overview-directory button:hover,
.admin-panel-feature-grid button:hover {
border-color: rgba(116, 214, 111, 0.24);
color: var(--admin-green);
background: #1a1d1a;
}
.admin-panel-overview-directory button > svg:first-child,
.admin-panel-feature-grid button > svg:first-child {
color: var(--admin-green);
}
.admin-panel-overview-directory button > span,
.admin-panel-feature-grid button > span {
display: grid;
min-width: 0;
gap: 2px;
}
.admin-panel-overview-directory strong,
.admin-panel-feature-grid strong {
overflow: hidden;
color: var(--admin-text);
font-size: 10px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-overview-directory small,
.admin-panel-feature-grid small {
overflow: hidden;
color: var(--admin-muted);
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-search {
height: 44px;
display: flex;
@@ -1621,6 +1962,16 @@ button:disabled {
box-shadow: inset 0 1px rgba(255, 255, 255, 0.015);
}
.admin-panel-section-card--focused {
min-height: 210px;
}
.admin-panel-feature-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.admin-panel-section-card__heading {
justify-content: space-between;
gap: 12px;
+21 -1
View File
@@ -811,7 +811,14 @@ const adminPanelFallbackLocales = {
navigation: 'Admin navigation',
refresh: 'Refresh admin data',
loading: 'Loading protected data...',
tabs: { players: 'Players', audit: 'Audit' },
tabs: {
overview: 'Overview',
players: 'Players',
devices: 'Devices',
apps: 'Apps',
security: 'Security',
audit: 'Audit',
},
overview: {
eyebrow: 'Live operations',
title: 'Command Center',
@@ -820,6 +827,16 @@ const adminPanelFallbackLocales = {
online: 'Online',
devices: 'Devices',
accounts: 'Accounts',
audit: 'Audit entries',
control: 'Admin modules',
features: 'Workspace areas',
featuresBody: 'Jump directly to a protected administration area.',
recent: 'Recent activity',
playerFeature: 'Identity, finances, job, and duty',
deviceFeature: 'IMEI, SIM, number, and activity',
appFeature: 'Install or remove phone apps',
securityFeature: 'Account access and protected credentials',
auditFeature: 'Review sensitive admin actions',
},
players: {
eyebrow: 'Active sessions',
@@ -852,6 +869,7 @@ const adminPanelFallbackLocales = {
devices: {
eyebrow: 'Device control',
title: 'Phones',
body: 'Inspect every phone assigned to the selected player.',
choose: 'Choose phone',
empty: 'No phone found',
emptyBody: 'This player currently has no phone device that can be managed.',
@@ -859,6 +877,8 @@ const adminPanelFallbackLocales = {
noSim: 'No SIM',
imei: 'IMEI',
updated: 'Last activity',
apps: 'Claimed apps',
account: 'Linked account',
},
credentials: {
eyebrow: 'Protected data',
+3 -3
View File
@@ -203,12 +203,12 @@ Locales["de"] = {
},
AdminPanel = {
name = "Kommandozentrale", subtitle = "Geschützte Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
tabs = { players = "Spieler", audit = "Audit" },
overview = { eyebrow = "Live-Betrieb", title = "Kommandozentrale", body = "Verwalte aktive Handys, App-Zugriffe und geschützte Accountdaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts" },
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", security = "Sicherheit", audit = "Audit" },
overview = { eyebrow = "Live-Betrieb", title = "Kommandozentrale", body = "Verwalte aktive Handys, App-Zugriffe und geschützte Accountdaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Admin-Module", features = "Arbeitsbereiche", featuresBody = "Öffne direkt einen geschützten Verwaltungsbereich.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", securityFeature = "Accountzugriff und geschützte Zugangsdaten", auditFeature = "Sensible Admin-Aktionen prüfen" },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
devices = { eyebrow = "Gerätesteuerung", title = "Handys", choose = "Handy auswählen", empty = "Kein Handy gefunden", emptyBody = "Dieser Spieler besitzt aktuell kein verwaltbares Handy.", noNumber = "Keine Telefonnummer", noSim = "Keine SIM", imei = "IMEI", updated = "Letzte Aktivität" },
devices = { eyebrow = "Gerätesteuerung", title = "Handys", body = "Prüfe alle Handys des ausgewählten Spielers.", choose = "Handy auswählen", empty = "Kein Handy gefunden", emptyBody = "Dieser Spieler besitzt aktuell kein verwaltbares Handy.", noNumber = "Keine Telefonnummer", noSim = "Keine SIM", imei = "IMEI", updated = "Letzte Aktivität", apps = "Zugewiesene Apps", account = "Verknüpfter Account" },
credentials = { eyebrow = "Geschützte Daten", title = "Zugangsdaten", email = "iFruit-E-Mail", password = "iFruit-Passwort", reveal = "Anzeigen", copy = "Passwort kopieren", copied = "Passwort kopiert.", noAccount = "Mit diesem Handy ist kein iFruit-Account verknüpft.", passcode = "Gerätecode", passcodeHashed = "{length}-stellige PIN · sicher gehasht und nicht wiederherstellbar", passcodeDisabled = "Kein Gerätecode eingerichtet", revealTitle = "Geschütztes Passwort anzeigen?", revealBody = "Diese Aktion wird serverseitig autorisiert, rate-limitiert und im Admin-Audit protokolliert.", cancel = "Abbrechen", confirmReveal = "Passwort anzeigen" },
apps = { eyebrow = "Fernverwaltung", title = "App-Zugriff", description = "Lege App-Zugriffe für dieses Gerät als Entwurf fest. Erst Speichern übernimmt die Änderungen.", installed = "Installiert", available = "Verfügbar", protected = "System-App", changes = "{count} offene Änderungen" },
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt" } },
+3 -3
View File
@@ -203,12 +203,12 @@ Locales["en"] = {
},
AdminPanel = {
name = "Command Center", subtitle = "Protected administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
tabs = { players = "Players", audit = "Audit" },
overview = { eyebrow = "Live operations", title = "Command Center", body = "Manage active phone devices, app access, and protected account data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts" },
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", security = "Security", audit = "Audit" },
overview = { eyebrow = "Live operations", title = "Command Center", body = "Manage active phone devices, app access, and protected account data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Admin modules", features = "Workspace areas", featuresBody = "Jump directly to a protected administration area.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", securityFeature = "Account access and protected credentials", auditFeature = "Review sensitive admin actions" },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
devices = { eyebrow = "Device control", title = "Phones", choose = "Choose phone", empty = "No phone found", emptyBody = "This player currently has no phone device that can be managed.", noNumber = "No phone number", noSim = "No SIM", imei = "IMEI", updated = "Last activity" },
devices = { eyebrow = "Device control", title = "Phones", body = "Inspect every phone assigned to the selected player.", choose = "Choose phone", empty = "No phone found", emptyBody = "This player currently has no phone device that can be managed.", noNumber = "No phone number", noSim = "No SIM", imei = "IMEI", updated = "Last activity", apps = "Claimed apps", account = "Linked account" },
credentials = { eyebrow = "Protected data", title = "Credentials", email = "iFruit email", password = "iFruit password", reveal = "Reveal", copy = "Copy password", copied = "Password copied.", noAccount = "No iFruit account is linked to this phone.", passcode = "Device passcode", passcodeHashed = "{length}-digit PIN · securely hashed and not recoverable", passcodeDisabled = "No passcode configured", revealTitle = "Reveal protected password?", revealBody = "This action is server-authorized, rate-limited, and written to the admin audit log.", cancel = "Cancel", confirmReveal = "Reveal password" },
apps = { eyebrow = "Remote management", title = "App access", description = "Stage app access for this device. Nothing changes until you save.", installed = "Installed", available = "Available", protected = "System app", changes = "{count} pending changes" },
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed" } },