Desktop D2 UI: design system, portfolio, release timeline, catalog parity, listings, health, reviews, TestFlight, checks, command palette, toasts, first-run guide, en + tr with i18n check, 7:1 theme check, dev fixture mode and screenshot harness
This commit is contained in:
parent
95b7cf7ced
commit
2ef0769730
44 changed files with 5475 additions and 742 deletions
|
|
@ -1,12 +1,16 @@
|
|||
// Store accounts: the keys live in the system keychain; the app keeps a label only and hands a key to storebound in
|
||||
// memory for one call.
|
||||
import { useState } from 'react';
|
||||
import { type Account, api, errorText } from '../api';
|
||||
import type { Data } from '../App';
|
||||
import { t } from '../i18n';
|
||||
import { date } from '../lib/format';
|
||||
import { useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { Card, Chip, Empty, Notice } from '../ui/kit';
|
||||
|
||||
const KIND: Record<Account['kind'], string> = { play: 'Google Play', apple: 'App Store Connect' };
|
||||
|
||||
export function Accounts({ data }: { data: Data }) {
|
||||
export function Accounts() {
|
||||
const d = useData();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [playLabel, setPlayLabel] = useState('');
|
||||
const [appleLabel, setAppleLabel] = useState('');
|
||||
|
|
@ -14,75 +18,71 @@ export function Accounts({ data }: { data: Data }) {
|
|||
const [issuerId, setIssuerId] = useState('');
|
||||
|
||||
const act = async (key: string, fn: () => Promise<Account | null | void>) => {
|
||||
setBusy(key); setError(null); setSaved(null);
|
||||
setBusy(key); setError(null);
|
||||
try {
|
||||
const r = await fn();
|
||||
if (r) setSaved(`Saved "${r.label}" in the system keychain.`);
|
||||
await data.reload();
|
||||
if (r) d.toast({ kind: 'ok', title: t('accounts.saved', { label: r.label }), detail: t('accounts.savedDetail') });
|
||||
await d.reload();
|
||||
} catch (e) { setError(errorText(e)); } finally { setBusy(null); }
|
||||
};
|
||||
const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel); if (a) setPlayLabel(''); return a; });
|
||||
const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; });
|
||||
const used = (a: Account) => data.projects.filter(p => p.playAccount === a.id || p.appleAccount === a.id).map(p => p.name);
|
||||
const used = (a: Account) => d.projects.filter(p => p.playAccount === a.id || p.appleAccount === a.id).map(p => p.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<div>
|
||||
<h1>Accounts</h1>
|
||||
<p>Store API keys live in the system keychain (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). They are never shown again, never written to your app folders and only handed to storebound in memory for a call.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="stack">
|
||||
{error && <div className="notice notice-bad" role="alert">{error}</div>}
|
||||
{saved && <div className="notice notice-ok" role="status">{saved}</div>}
|
||||
<div className="card">
|
||||
{!data.accounts.length ? (
|
||||
<div className="empty"><h2>No accounts</h2><p>Add a Google Play service account or an App Store Connect API key below.</p></div>
|
||||
) : (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Store</th><th>Used by</th><th>Added</th><th><span className="sr">Actions</span></th></tr></thead>
|
||||
<tbody>
|
||||
{data.accounts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td className="name">{a.label}</td>
|
||||
<td><span className={`tag ${a.kind === 'play' ? 'tag-ok' : 'tag-local'}`}>{KIND[a.kind]}</span></td>
|
||||
<td>{used(a).join(', ') || <span className="tag tag-muted">no project</span>}</td>
|
||||
<td>{a.created.slice(0, 10)}</td>
|
||||
<td style={{ textAlign: 'right' }}><button className="btn btn-quiet" disabled={!!busy} onClick={() => act(`rm-${a.id}`, () => api.removeAccount(a.id))} aria-label={`Remove ${a.label} and delete its key from the keychain`}>Remove</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid2">
|
||||
<form className="card card-pad form" onSubmit={e => { e.preventDefault(); addPlay(); }}>
|
||||
<div><h2>Google Play</h2><p className="sub">Service account JSON key with access to your apps in Play Console.</p></div>
|
||||
<div className="page">
|
||||
<div className="ph"><div><h1>{t('nav.accounts')}</h1><p>{t('accounts.lead')}</p></div></div>
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
<Card title={t('accounts.list')} icon="key" sub={t('accounts.listSub')} pad={false}>
|
||||
{!d.accounts.length ? <Empty illo="key" title={t('accounts.emptyTitle')}>{t('accounts.emptyText')}</Empty> : (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.name')}</th><th>{t('col.store')}</th><th>{t('col.usedBy')}</th><th>{t('col.added')}</th><th><span className="sr">{t('col.actions')}</span></th></tr></thead>
|
||||
<tbody>
|
||||
{d.accounts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td className="strong">{a.label}</td>
|
||||
<td><span className="row" style={{ gap: 8 }}><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} className="ic14" />{a.kind === 'play' ? 'Google Play' : 'App Store Connect'}</span></td>
|
||||
<td>{used(a).length ? used(a).join(', ') : <Chip tone="neutral">{t('accounts.unused')}</Chip>}</td>
|
||||
<td className="muted">{date(a.created)}</td>
|
||||
<td className="right"><button className="btn btn-sm btn-ghost btn-danger" disabled={!!busy} onClick={() => act(`rm-${a.id}`, () => api.removeAccount(a.id))} aria-label={t('accounts.removeAria', { label: a.label })}><Icon name="trash" />{t('accounts.remove')}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
<div className="grid g2">
|
||||
<Card title="Google Play" icon="key" sub={t('accounts.playSub')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addPlay(); }}>
|
||||
<div className="field">
|
||||
<label htmlFor="play-label">Name</label>
|
||||
<input id="play-label" type="text" value={playLabel} onChange={e => setPlayLabel(e.target.value)} placeholder="Studio Play account" maxLength={60} autoComplete="off" />
|
||||
<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>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!playLabel.trim() || !!busy}>Choose JSON key</button></div>
|
||||
<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>
|
||||
<form className="card card-pad form" onSubmit={e => { e.preventDefault(); addApple(); }}>
|
||||
<div><h2>App Store Connect</h2><p className="sub">Team API key (.p8) from Users and Access, Integrations.</p></div>
|
||||
</Card>
|
||||
<Card title="App Store Connect" icon="key" sub={t('accounts.appleSub')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addApple(); }}>
|
||||
<div className="field">
|
||||
<label htmlFor="apple-label">Name</label>
|
||||
<input id="apple-label" type="text" value={appleLabel} onChange={e => setAppleLabel(e.target.value)} placeholder="Studio App Store key" maxLength={60} autoComplete="off" />
|
||||
<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>
|
||||
<div className="field">
|
||||
<label htmlFor="key-id">Key ID</label>
|
||||
<input id="key-id" type="text" value={keyId} onChange={e => setKeyId(e.target.value.toUpperCase())} placeholder="10 letters or digits" maxLength={10} autoComplete="off" spellCheck={false} />
|
||||
<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 className="field">
|
||||
<label htmlFor="issuer-id">Issuer ID</label>
|
||||
<input id="issuer-id" type="text" value={issuerId} onChange={e => setIssuerId(e.target.value)} placeholder="UUID shown above the keys" maxLength={36} autoComplete="off" spellCheck={false} />
|
||||
</div>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!appleLabel.trim() || keyId.length !== 10 || issuerId.length < 36 || !!busy}>Choose .p8 key</button></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>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
<Notice tone="info" icon="shield">{t('accounts.safety')}</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue