// Studios & accounts: studios (name, colour, logo) group store accounts; each account is a Google Play service account // (one JSON key; it can be invited to several Play developer accounts) or an App Store Connect team key (key id, // issuer id, .p8). Keys live in the machine's credential profiles (~/.storebound, shared with the CLI); // the app keeps a label, the studio and the profile name only. Per account: a health // check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see). import { type MenuInput, useContextMenu, useMenuButton } from '../ui/ContextMenu'; import { copyToast } from '../ui/app-menu'; import { type FormEvent, useEffect, useState } from 'react'; import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api'; import { type Key, t } from '../i18n'; import { ago } from '../lib/format'; import { withIgnored } from '../lib/identity'; import { type AccountHealth, useData, useReadState } from '../store'; import { accountCheckKey, importKey } from '../lib/read-registry'; import { ImportAppsButton, TestAccountButton } from '../ui/ReadButtons'; import { Icon, StoreGlyph } from '../ui/icons'; import { Chip, Notice, StudioMark } from '../ui/kit'; import { EmptyState, PageHeader, Section, StatTile } from '../ui/ds'; import '../styles/accounts.css'; import type { Tone } from '../lib/model'; const STATUS: Record = { ok: ['ok', 'acct.ok'], 'no-apps': ['warn', 'acct.noApps'], permission: ['warn', 'acct.permission'], revoked: ['bad', 'acct.revoked'], 'api-disabled': ['warn', 'acct.apiDisabled'], error: ['bad', 'acct.error'], }; export function HealthChip({ h }: { h: AccountHealth | undefined }) { if (!h) return {t('acct.notTested')}; if (h.error || !h.check) return {t('acct.error')}; const [tone, key] = STATUS[h.check.status]; return {t(key)}; } export function Swatches({ value, onChange, name }: { value: StudioColor; onChange: (c: StudioColor) => void; name: string }) { return (
{STUDIO_COLORS.map(c => ( ))} {name}
); } function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | null; setBusy: (b: string | null) => void; onError: (e: string | null) => void }) { const d = useData(); const [edit, setEdit] = useState(false); const [label, setLabel] = useState(a.label); const [studio, setStudio] = useState(a.studio ?? ''); const [dev, setDev] = useState(a.developerId ?? ''); const devOk = !dev.trim() || /^\d{1,25}$/.test(dev.trim()); const h = d.accountHealth[a.id]; const list = d.storeApps[a.id]; const ignored = d.overrides.ignored.includes(`account:${a.id}`); const used = d.projects.filter(p => p.playAccount === a.id || p.appleAccount === a.id).length; const run = async (key: string, fn: () => Promise) => { setBusy(key); onError(null); try { await fn(); } catch (e) { onError(errorText(e)); } finally { setBusy(null); } }; const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); if (a.kind === 'play' && a.profile && dev.trim() !== (a.developerId ?? '')) await api.setPlayDeveloper(a.id, dev.trim() || null); await d.reload(); setEdit(false); }); }; const check = h?.check; // Test and Import of this account: busy from the read registry (Test all / Import apps make them busy too) const ck = useReadState(accountCheckKey(a.id)); const im = useReadState(importKey(a.id)); const cm = useContextMenu(); const more = useMenuButton(); const menu = (): MenuInput => [ { label: t('acct.test'), icon: 'shield', disabled: ck.busy ? t('cm.busy') : false, onSelect: () => { d.checkAccount(a.id).catch(() => {}); } }, { label: t('acct.import'), icon: 'download', disabled: im.busy ? t('cm.busy') : false, onSelect: () => { d.importApps(a.id).catch(() => {}); } }, { label: t('acct.edit'), icon: 'edit', onSelect: () => setEdit(true) }, 'sep', { label: t('cm.copy'), icon: 'copy', submenu: [ { label: t('cm.copyName'), icon: 'text', onSelect: () => copyToast(d, a.label) }, check?.principal && { label: t('cm.copyPrincipal'), icon: 'key', shortcut: check.principal, onSelect: () => copyToast(d, check.principal!) }, a.profile && { label: t('cm.copyProfile'), icon: 'folder', shortcut: a.profile, onSelect: () => copyToast(d, a.profile!) }, ] }, 'sep', { label: ignored ? t('acct.unignore') : t('acct.ignore'), icon: ignored ? 'eye' : 'eyeOff', onSelect: () => { void d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], !ignored)); } }, { label: t('accounts.remove'), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => run(`rm-${a.id}`, async () => { await api.removeAccount(a.id); await d.reload(); }) }, ]; return (
{a.label}{ignored && {t('acct.ignored')}}
{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')} {' · '}{a.profile ? {t('acct.profile', { name: a.profile })} : t('acct.noProfile')} {' · '}{list ? t('acct.appsRead', { count: list.value.apps.length, when: ago(list.at) }) : t('acct.notImported')} {used ? ` · ${t('acct.folders', { count: used })}` : ''} {a.kind === 'play' && (a.developerId ? <> · {t('acct.devId', { id: a.developerId })} : <> · {t('acct.noDevId')})} {check?.principal ? <> · {check.principal} : null}
{edit && (
setLabel(e.target.value)} maxLength={60} />
{a.kind === 'play' && a.profile &&
setDev(e.target.value)} placeholder="5550000000000000001" aria-invalid={!devOk} aria-describedby={`adh-${a.id}`} /> {t('acct.devIdHint')}
}
)} {check && check.status !== 'ok' && check.fix && (
api.openExternal(check.docs!).catch(e => onError(errorText(e)))}>{t('acct.openFix')} : undefined}> {check.message}
{check.fix}
)} {h?.error &&
{h.error}
}
); } function StudioCard({ s, accounts, focus }: { s: Studio | null; accounts: Account[]; focus: boolean }) { const d = useData(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [edit, setEdit] = useState(false); const [name, setName] = useState(s?.name ?? ''); const [color, setColor] = useState(s?.color ?? 'indigo'); const apps = d.apps.filter(a => !a.ignored && (a.studio ?? null) === (s?.id ?? null)); const storeOnly = apps.filter(a => !a.project).length; const act = async (key: string, fn: () => Promise) => { setBusy(key); setError(null); try { await fn(); await d.reload(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); } }; const id = s ? `studio-${s.id}` : 'studio-none'; const more = useMenuButton(); useEffect(() => { if (focus) document.getElementById(id)?.scrollIntoView({ block: 'start' }); }, [focus, id]); return (

{s?.name ?? t('studio.none')}

{s ? t('studio.counts', { count: accounts.length, apps: apps.length, store: storeOnly }) : t('studio.noneSub')}
{s && (
)}
{s && edit && (
{ e.preventDefault(); act('save', async () => { await api.saveStudio(s.id, name, color); setEdit(false); }); }}>
setName(e.target.value)} maxLength={48} />
{t('studio.color')}
)} {error &&
{error}
} {accounts.length ?
{accounts.map(a => )}
:

{s ? t('studio.noAccounts') : t('studio.allAssigned')}

}
); } export function Accounts({ focus }: { focus?: string }) { const d = useData(); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); const [studioName, setStudioName] = useState(''); const [studioColor, setStudioColor] = useState('indigo'); const [playLabel, setPlayLabel] = useState(''); const [playStudio, setPlayStudio] = useState(''); const [appleLabel, setAppleLabel] = useState(''); const [appleStudio, setAppleStudio] = useState(''); const [keyId, setKeyId] = useState(''); const [issuerId, setIssuerId] = useState(''); const act = async (key: string, fn: () => Promise, ok?: (r: Account | Studio) => string) => { setBusy(key); setError(null); try { const r = await fn(); if (r && ok) d.toast({ kind: 'ok', title: ok(r), detail: 'kind' in r ? t('accounts.savedDetail') : undefined }); await d.reload(); return r; } catch (e) { setError(errorText(e)); } finally { setBusy(null); } }; const addStudio = () => act('studio', async () => { const s = await api.saveStudio(null, studioName, studioColor); setStudioName(''); return s; }, r => t('studio.added', { name: (r as Studio).name })); const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel, playStudio || null); if (a) setPlayLabel(''); return a; }, r => t('accounts.saved', { label: (r as Account).label })); const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId, appleStudio || null); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; }, r => t('accounts.saved', { label: (r as Account).label })); const unassigned = d.accounts.filter(a => !a.studio || !d.studios.some(s => s.id === a.studio)); const studioSelect = (id: string, value: string, set: (v: string) => void) => (
); return (
} /> {d.accounts.length > 0 &&
a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length })} /> d.accountHealth[a.id]?.check?.status === 'ok').length} /> { const h = d.accountHealth[a.id]; return !!h && (!!h.error || (!!h.check && h.check.status !== 'ok')); }) ? 'bad' : undefined} label={t('v2cfg.keysFailing')} value={d.accounts.filter(a => { const h = d.accountHealth[a.id]; return !!h && (!!h.error || (!!h.check && h.check.status !== 'ok')); }).length} />
} {error && {error}} {!d.accounts.length && !d.studios.length &&
{t('accounts.emptyTitle')} {t('accounts.emptyTextStudios')}
} {d.studios.map(s => a.studio === s.id)} focus={focus === s.id} />)} {(unassigned.length > 0 || !d.studios.length) && d.accounts.length > 0 && }
{ e.preventDefault(); addStudio(); }}>
setStudioName(e.target.value)} maxLength={48} placeholder={t('studio.namePlaceholder')} autoComplete="off" />
{t('studio.color')}
{ e.preventDefault(); addPlay(); }}>
setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" />
{studioSelect('play-studio', playStudio, setPlayStudio)}

{t('accounts.playHow')}

{ e.preventDefault(); addApple(); }}>
setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" />
{studioSelect('apple-studio', appleStudio, setAppleStudio)}
setKeyId(e.target.value.toUpperCase())} placeholder="ABC123DEFG" maxLength={10} autoComplete="off" spellCheck={false} />
setIssuerId(e.target.value)} placeholder="UUID" maxLength={36} autoComplete="off" spellCheck={false} />
{t('accounts.safetyStudios')}
); }