One ContextMenu in the design system (keyboard, type-ahead, submenus, check items, disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all. Menus on sidebar and portfolio apps, app hero, releases, store art, live art, reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
250 lines
20 KiB
TypeScript
250 lines
20 KiB
TypeScript
// 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 { useContextMenu } 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 } from '../store';
|
|
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<AccountCheck['status'], [Tone, Key]> = {
|
|
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 <Chip tone="neutral">{t('acct.notTested')}</Chip>;
|
|
if (h.error || !h.check) return <span title={h.error}><Chip tone="bad">{t('acct.error')}</Chip></span>;
|
|
const [tone, key] = STATUS[h.check.status];
|
|
return <span title={h.check.message}><Chip tone={tone}>{t(key)}</Chip></span>;
|
|
}
|
|
|
|
export function Swatches({ value, onChange, name }: { value: StudioColor; onChange: (c: StudioColor) => void; name: string }) {
|
|
return (
|
|
<div className="swatches" role="radiogroup" aria-label={t('studio.color')}>
|
|
{STUDIO_COLORS.map(c => (
|
|
<button key={c} type="button" role="radio" aria-checked={value === c} className={`swatch studio-${c}`} onClick={() => onChange(c)} aria-label={t(`color.${c}` as Key)} title={t(`color.${c}` as Key)}>
|
|
{value === c && <Icon name="check" />}
|
|
</button>
|
|
))}
|
|
<span className="sr">{name}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 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<unknown>) => {
|
|
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); await d.reload(); setEdit(false); }); };
|
|
const check = h?.check;
|
|
const cm = useContextMenu();
|
|
return (
|
|
<div className={`acct${ignored ? ' is-ignored' : ''}`} {...cm(() => [
|
|
{ label: t('acct.test'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => run(`ck-${a.id}`, () => d.checkAccount(a.id)) },
|
|
{ label: t('acct.import'), icon: 'download', disabled: busy || d.importBusy ? t('cm.busy') : false, onSelect: () => run(`im-${a.id}`, () => d.importApps(a.id)) },
|
|
{ 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(); }) },
|
|
])}>
|
|
<div className="acct-row">
|
|
<span className={`acct-glyph ${a.kind}`}><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} /></span>
|
|
<div className="acct-main">
|
|
<div className="row" style={{ gap: 8 }}><span className="strong">{a.label}</span><HealthChip h={h} />{ignored && <Chip tone="neutral" icon="eyeOff">{t('acct.ignored')}</Chip>}</div>
|
|
<div className="small muted">
|
|
{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')}
|
|
{' · '}{a.profile ? <span className="mono-id selectable" title="~/.storebound">{t('acct.profile', { name: a.profile })}</span> : 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 })}` : ''}
|
|
{check?.principal ? <> · <span className="mono-id selectable">{check.principal}</span></> : null}
|
|
</div>
|
|
</div>
|
|
<div className="actions">
|
|
<button className="btn btn-sm" onClick={() => run(`ck-${a.id}`, () => d.checkAccount(a.id))} disabled={!!busy}><Icon name="shield" className={busy === `ck-${a.id}` ? 'spin' : undefined} />{t('acct.test')}</button>
|
|
<button className="btn btn-sm" onClick={() => run(`im-${a.id}`, () => d.importApps(a.id))} disabled={!!busy || d.importBusy}><Icon name="download" />{t('acct.import')}</button>
|
|
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => setEdit(x => !x)} aria-expanded={edit} aria-label={t('acct.editAria', { label: a.label })} title={t('acct.edit')}><Icon name="edit" /></button>
|
|
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], !ignored))} aria-label={ignored ? t('acct.unignore') : t('acct.ignore')} title={ignored ? t('acct.unignore') : t('acct.ignoreHint')}><Icon name={ignored ? 'eye' : 'eyeOff'} /></button>
|
|
<button className="btn btn-sm btn-ghost btn-icon btn-danger" disabled={!!busy} onClick={() => run(`rm-${a.id}`, async () => { await api.removeAccount(a.id); await d.reload(); })} aria-label={t('accounts.removeAria', { label: a.label })} title={t('accounts.remove')}><Icon name="trash" /></button>
|
|
</div>
|
|
</div>
|
|
{edit && (
|
|
<form className="acct-edit" onSubmit={save}>
|
|
<div className="field"><label htmlFor={`al-${a.id}`}>{t('accounts.label')}</label><input id={`al-${a.id}`} className="input" value={label} onChange={e => setLabel(e.target.value)} maxLength={60} /></div>
|
|
<div className="field"><label htmlFor={`as-${a.id}`}>{t('studio.studio')}</label>
|
|
<select id={`as-${a.id}`} className="input" value={studio} onChange={e => setStudio(e.target.value)}>
|
|
<option value="">{t('studio.none')}</option>
|
|
{d.studios.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
</select></div>
|
|
<div className="actions"><button className="btn btn-primary btn-sm" type="submit" disabled={!label.trim() || !!busy}><Icon name="check" />{t('common.save')}</button><button type="button" className="btn btn-sm btn-ghost" onClick={() => setEdit(false)}>{t('common.cancel')}</button></div>
|
|
</form>
|
|
)}
|
|
{check && check.status !== 'ok' && check.fix && (
|
|
<div className="acct-fix">
|
|
<Notice tone={check.status === 'revoked' ? 'bad' : 'warn'} action={check.docs ? <button className="btn btn-sm" onClick={() => api.openExternal(check.docs!).catch(e => onError(errorText(e)))}><Icon name="external" />{t('acct.openFix')}</button> : undefined}>
|
|
<b>{check.message}</b><br />{check.fix}
|
|
</Notice>
|
|
</div>
|
|
)}
|
|
{h?.error && <div className="acct-fix"><Notice tone="bad">{h.error}</Notice></div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StudioCard({ s, accounts, focus }: { s: Studio | null; accounts: Account[]; focus: boolean }) {
|
|
const d = useData();
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [edit, setEdit] = useState(false);
|
|
const [name, setName] = useState(s?.name ?? '');
|
|
const [color, setColor] = useState<StudioColor>(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<unknown>) => { 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';
|
|
useEffect(() => { if (focus) document.getElementById(id)?.scrollIntoView({ block: 'start' }); }, [focus, id]);
|
|
return (
|
|
<section className={`ds-sec studio-card${focus ? ' focus' : ''}`} id={id} aria-labelledby={`${id}-h`}>
|
|
<div className="card-head">
|
|
<div className="row" style={{ gap: 12, minWidth: 0 }}>
|
|
<StudioMark studio={s} size="lg" />
|
|
<div className="stack" style={{ gap: 2, minWidth: 0 }}>
|
|
<h2 id={`${id}-h`}>{s?.name ?? t('studio.none')}</h2>
|
|
<div className="sub">{s ? t('studio.counts', { count: accounts.length, apps: apps.length, store: storeOnly }) : t('studio.noneSub')}</div>
|
|
</div>
|
|
</div>
|
|
{s && (
|
|
<div className="actions">
|
|
<button className="btn btn-sm btn-ghost" onClick={() => setEdit(x => !x)} aria-expanded={edit}><Icon name="edit" />{t('studio.edit')}</button>
|
|
<button className="btn btn-sm btn-ghost" onClick={() => act('logo', () => api.studioLogo(s.id))} disabled={!!busy}><Icon name="image" />{t('studio.logo')}</button>
|
|
{s.logo && <button className="btn btn-sm btn-ghost" onClick={() => act('logo', () => api.studioLogo(s.id, true))} disabled={!!busy}>{t('studio.logoClear')}</button>}
|
|
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => act('rm', () => api.removeStudio(s.id))} disabled={!!busy} title={t('studio.removeHint')}><Icon name="trash" />{t('studio.remove')}</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{s && edit && (
|
|
<form className="card-body studio-edit" onSubmit={e => { e.preventDefault(); act('save', async () => { await api.saveStudio(s.id, name, color); setEdit(false); }); }}>
|
|
<div className="field"><label htmlFor={`${id}-n`}>{t('studio.name')}</label><input id={`${id}-n`} className="input" value={name} onChange={e => setName(e.target.value)} maxLength={48} /></div>
|
|
<div className="field"><span className="legend">{t('studio.color')}</span><Swatches value={color} onChange={setColor} name={name} /></div>
|
|
<div className="actions"><button className="btn btn-primary btn-sm" type="submit" disabled={!name.trim() || !!busy}><Icon name="check" />{t('common.save')}</button></div>
|
|
</form>
|
|
)}
|
|
{error && <div className="card-body"><Notice tone="bad">{error}</Notice></div>}
|
|
{accounts.length ? <div className="acct-list">{accounts.map(a => <AccountRow key={a.id} a={a} busy={busy} setBusy={setBusy} onError={setError} />)}</div>
|
|
: <div className="card-body"><p className="small muted">{s ? t('studio.noAccounts') : t('studio.allAssigned')}</p></div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function Accounts({ focus }: { focus?: string }) {
|
|
const d = useData();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [studioName, setStudioName] = useState('');
|
|
const [studioColor, setStudioColor] = useState<StudioColor>('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<Account | Studio | null | void>, 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 testAll = async () => { setBusy('all'); await Promise.all(d.accounts.map(a => d.checkAccount(a.id))); setBusy(null); };
|
|
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) => (
|
|
<div className="field"><label htmlFor={id}>{t('studio.studio')}</label>
|
|
<select id={id} className="input" value={value} onChange={e => set(e.target.value)}>
|
|
<option value="">{t('studio.none')}</option>
|
|
{d.studios.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
</select></div>
|
|
);
|
|
|
|
return (
|
|
<div className="page scr-accounts">
|
|
<PageHeader icon="key" eyebrow={t('v2cfg.workspace')} title={t('nav.accounts')} subtitle={t('accounts.leadStudios')} actions={<>
|
|
<button className="btn" onClick={testAll} disabled={!!busy || !d.accounts.length}><Icon name="shield" className={busy === 'all' ? 'spin' : undefined} />{t('acct.testAll')}</button>
|
|
<button className="btn btn-primary" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
|
|
</>} />
|
|
{d.accounts.length > 0 && <div className="ds-grid acct-kpis">
|
|
<StatTile icon="building" label={t('v2cfg.studios')} value={d.studios.length} />
|
|
<StatTile icon="key" label={t('v2cfg.accounts')} value={d.accounts.length} sub={t('v2cfg.perStore', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length })} />
|
|
<StatTile icon="check" tone="ok" label={t('v2cfg.keysWork')} value={d.accounts.filter(a => d.accountHealth[a.id]?.check?.status === 'ok').length} />
|
|
<StatTile icon="alert" tone={d.accounts.some(a => { 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} />
|
|
</div>}
|
|
{error && <Notice tone="bad">{error}</Notice>}
|
|
{!d.accounts.length && !d.studios.length && <Section><EmptyState icon="key"><b>{t('accounts.emptyTitle')}</b> {t('accounts.emptyTextStudios')}</EmptyState></Section>}
|
|
{d.studios.map(s => <StudioCard key={s.id} s={s} accounts={d.accounts.filter(a => a.studio === s.id)} focus={focus === s.id} />)}
|
|
{(unassigned.length > 0 || !d.studios.length) && d.accounts.length > 0 && <StudioCard s={null} accounts={unassigned} focus={focus === 'none'} />}
|
|
|
|
<div className="acct-add">
|
|
<Section title={t('studio.new')} icon="building" sub={t('studio.newSub')}>
|
|
<form className="form" onSubmit={e => { e.preventDefault(); addStudio(); }}>
|
|
<div className="field"><label htmlFor="studio-name">{t('studio.name')}</label><input id="studio-name" className="input" value={studioName} onChange={e => setStudioName(e.target.value)} maxLength={48} placeholder={t('studio.namePlaceholder')} autoComplete="off" /></div>
|
|
<div className="field"><span className="legend">{t('studio.color')}</span><Swatches value={studioColor} onChange={setStudioColor} name={studioName} /></div>
|
|
<div><button className="btn btn-primary" type="submit" disabled={!studioName.trim() || !!busy}><Icon name="plus" />{t('studio.add')}</button></div>
|
|
</form>
|
|
</Section>
|
|
<Section title="Google Play" icon="key" sub={t('accounts.playSubMany')}>
|
|
<form className="form" onSubmit={e => { e.preventDefault(); addPlay(); }}>
|
|
<div className="field"><label htmlFor="play-label">{t('accounts.label')}</label>
|
|
<input id="play-label" className="input" type="text" value={playLabel} onChange={e => setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" /></div>
|
|
{studioSelect('play-studio', playStudio, setPlayStudio)}
|
|
<p className="small muted">{t('accounts.playHow')}</p>
|
|
<div><button className="btn btn-primary" type="submit" disabled={!playLabel.trim() || !!busy}><Icon name="key" />{t('accounts.choosePlay')}</button></div>
|
|
</form>
|
|
</Section>
|
|
<Section title="App Store Connect" icon="key" sub={t('accounts.appleSubMany')}>
|
|
<form className="form" onSubmit={e => { e.preventDefault(); addApple(); }}>
|
|
<div className="field"><label htmlFor="apple-label">{t('accounts.label')}</label>
|
|
<input id="apple-label" className="input" type="text" value={appleLabel} onChange={e => setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" /></div>
|
|
{studioSelect('apple-studio', appleStudio, setAppleStudio)}
|
|
<div className="grid g2" style={{ gap: 12 }}>
|
|
<div className="field"><label htmlFor="key-id">{t('accounts.keyId')}</label>
|
|
<input id="key-id" className="input mono" type="text" value={keyId} onChange={e => setKeyId(e.target.value.toUpperCase())} placeholder="ABC123DEFG" maxLength={10} autoComplete="off" spellCheck={false} /></div>
|
|
<div className="field"><label htmlFor="issuer-id">{t('accounts.issuerId')}</label>
|
|
<input id="issuer-id" className="input mono" type="text" value={issuerId} onChange={e => setIssuerId(e.target.value)} placeholder="UUID" maxLength={36} autoComplete="off" spellCheck={false} /></div>
|
|
</div>
|
|
<div><button className="btn btn-primary" type="submit" disabled={!appleLabel.trim() || keyId.length !== 10 || issuerId.length < 36 || !!busy}><Icon name="key" />{t('accounts.chooseApple')}</button></div>
|
|
</form>
|
|
</Section>
|
|
</div>
|
|
<Notice tone="info" icon="shield">{t('accounts.safetyStudios')}</Notice>
|
|
</div>
|
|
);
|
|
}
|