Repairs my own mistake rather than adding anything. The commit that moved Untranslated into components/ was meant to widen `reason` to 'foreign-ui-label' | 'product-name' at the same time; its `git add` listed the pre-move path with stderr suppressed, so the add failed silently, nothing but the `git mv` rename was staged, and the widening stayed in my working tree. Consequence, stated plainly: f4ab274d..fa32c82e do not typecheck in isolation, because the terminal panel uses reason="product-name" against a union that did not yet have it. The working tree was green throughout, which is exactly why I did not notice — the per-commit guarantee was broken while the visible state looked fine. Not rewriting the pushed history: master is shared and another session is pushing to it, and a force-push to fix a type union is the wrong trade. The lesson worth keeping is about `git add ... 2>/dev/null`, which turns a bad pathspec into a no-op that still lets the commit succeed.
320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
import type { FormEvent } from 'react';
|
|
import { useState, useEffect } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { ChevronDown } from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
import { Untranslated } from '@/components/Untranslated';
|
|
|
|
type GoogleOAuthSettings = {
|
|
clientId: string;
|
|
clientSecret: string;
|
|
};
|
|
|
|
// The scope strings are Google's own identifiers; only the explanation beside them is ours.
|
|
const SCOPES = [
|
|
{ scope: 'gmail.readonly', descriptionKey: 'settings.google.scopeGmail' },
|
|
{ scope: 'calendar.readonly', descriptionKey: 'settings.google.scopeCalendar' },
|
|
] as const;
|
|
|
|
const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const { t } = useTranslation();
|
|
|
|
/*
|
|
Every quoted Google-console label below is wrapped in <Untranslated>. Per docs/localization.md they
|
|
are identifiers, not prose: they exist to match what the reader sees on Google's screen, and Google
|
|
localises by the reader's Google account language rather than ours.
|
|
|
|
The sentences are keyed WHOLE, each ending at the label or link it introduces. The first attempt
|
|
split them around inline labels, which needs "Go to the" and "page." as separate keys — and the
|
|
Portuguese value of the second one is "." That is the fragmentation smell, so the English was
|
|
rephrased to put the label at a clause boundary, which reads naturally in both languages.
|
|
*/
|
|
const GoogleLink = ({ href, label }: { href: string; label: string }) => (
|
|
<a href={href} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
|
<Untranslated reason="foreign-ui-label">{label}</Untranslated>
|
|
</a>
|
|
);
|
|
|
|
return (
|
|
<Collapsible open={open} onOpenChange={setOpen}>
|
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
|
|
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
|
{t('settings.google.guideToggle')}
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
|
|
<li>
|
|
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step1.title')}</strong>
|
|
<p className="mt-1">
|
|
{t('settings.google.step1.body')}{' '}
|
|
<GoogleLink
|
|
href="https://console.cloud.google.com/projectcreate"
|
|
label={t('settings.google.step1.link')}
|
|
/>{' '}
|
|
→ <Untranslated reason="foreign-ui-label">Create</Untranslated>
|
|
</p>
|
|
</li>
|
|
|
|
<li>
|
|
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step2.title')}</strong>
|
|
<p className="mt-1">
|
|
{t('settings.google.step2.body')}{' '}
|
|
<GoogleLink
|
|
href="https://console.cloud.google.com/apis/library"
|
|
label={t('settings.google.step2.link')}
|
|
/>{' '}
|
|
→ <Untranslated reason="foreign-ui-label">Enable</Untranslated>
|
|
</p>
|
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
|
<li>
|
|
<Untranslated reason="foreign-ui-label">Gmail API</Untranslated>
|
|
</li>
|
|
<li>
|
|
<Untranslated reason="foreign-ui-label">Google Calendar API</Untranslated>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
|
|
<li>
|
|
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step3.title')}</strong>
|
|
<p className="mt-1">
|
|
<GoogleLink
|
|
href="https://console.cloud.google.com/auth/branding"
|
|
label={t('settings.google.step3.link')}
|
|
/>
|
|
</p>
|
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
|
<li>
|
|
{t('settings.google.step3.appName')} <Untranslated reason="foreign-ui-label">App name</Untranslated>
|
|
</li>
|
|
<li>
|
|
{t('settings.google.step3.supportEmail')}{' '}
|
|
<Untranslated reason="foreign-ui-label">User support email</Untranslated>
|
|
</li>
|
|
<li>
|
|
{t('settings.google.step3.devContact')}{' '}
|
|
<Untranslated reason="foreign-ui-label">Developer contact information</Untranslated>
|
|
</li>
|
|
<li>
|
|
{t('settings.google.step3.save')} <Untranslated reason="foreign-ui-label">Save</Untranslated>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
|
|
<li>
|
|
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step4.title')}</strong>
|
|
<p className="mt-1">
|
|
<GoogleLink
|
|
href="https://console.cloud.google.com/auth/audience"
|
|
label={t('settings.google.step4.link')}
|
|
/>
|
|
</p>
|
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
|
<li>
|
|
{t('settings.google.step4.internal')} <Untranslated reason="foreign-ui-label">Internal</Untranslated>
|
|
</li>
|
|
<li>
|
|
{t('settings.google.step4.external')} <Untranslated reason="foreign-ui-label">External</Untranslated>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
|
|
<li>
|
|
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step5.title')}</strong>
|
|
<p className="mt-1">
|
|
{t('settings.google.step5.body')}{' '}
|
|
<GoogleLink href="https://console.cloud.google.com/auth/scopes" label={t('settings.google.step5.link')} />{' '}
|
|
→ <Untranslated reason="foreign-ui-label">Add or remove scopes</Untranslated>
|
|
</p>
|
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
|
{SCOPES.map((entry) => (
|
|
<li key={entry.scope}>
|
|
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
|
|
{entry.scope}
|
|
</code>{' '}
|
|
— {t(entry.descriptionKey)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">{t('settings.google.step5.note')}</p>
|
|
</li>
|
|
|
|
<li>
|
|
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step6.title')}</strong>
|
|
<p className="mt-1">
|
|
{t('settings.google.step6.body')}{' '}
|
|
<GoogleLink
|
|
href="https://console.cloud.google.com/auth/clients"
|
|
label={t('settings.google.step6.link')}
|
|
/>{' '}
|
|
→ <Untranslated reason="foreign-ui-label">Create OAuth client</Untranslated>
|
|
</p>
|
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
|
<li>
|
|
{t('settings.google.step6.appType')}{' '}
|
|
<Untranslated reason="foreign-ui-label">Web application</Untranslated>
|
|
</li>
|
|
<li>{t('settings.google.step6.nameAnything')}</li>
|
|
<li>
|
|
{t('settings.google.step6.redirectUris')}{' '}
|
|
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all">
|
|
{redirectUri}
|
|
</code>
|
|
</li>
|
|
<li>
|
|
{t('settings.google.step6.thenClick')} <Untranslated reason="foreign-ui-label">Create</Untranslated>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
|
|
<li>
|
|
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step7.title')}</strong>
|
|
<p className="mt-1">{t('settings.google.step7.body')}</p>
|
|
</li>
|
|
</ol>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
};
|
|
|
|
type VerifyStatus = { valid: boolean; error: string | null } | null;
|
|
|
|
const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
|
|
const { t } = useTranslation();
|
|
if (isVerifying) {
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
|
<div className="h-2.5 w-2.5 rounded-full bg-duck-dark/20 dark:bg-foreground/20 animate-pulse shrink-0" />
|
|
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">{t('settings.google.verifying')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!status) return null;
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
|
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-success' : 'bg-red-500'}`} />
|
|
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
|
|
{status.valid ? t('settings.google.valid') : (status.error ?? t('settings.google.invalid'))}
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const GoogleOAuthConfig = () => {
|
|
const { t } = useTranslation();
|
|
const client = useClient();
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [clientId, setClientId] = useState('');
|
|
const [clientSecret, setClientSecret] = useState('');
|
|
const [verifyStatus, setVerifyStatus] = useState<VerifyStatus>(null);
|
|
const [isVerifying, setIsVerifying] = useState(false);
|
|
|
|
const verify = () => {
|
|
setIsVerifying(true);
|
|
client
|
|
.get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
|
|
.then(setVerifyStatus)
|
|
.catch(() => setVerifyStatus({ valid: false, error: t('settings.google.verifyFailed') }))
|
|
.finally(() => setIsVerifying(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
client
|
|
.get<GoogleOAuthSettings | null>('/integrations/google/config')
|
|
.then((data) => {
|
|
if (data) {
|
|
setClientId(data.clientId);
|
|
setClientSecret(data.clientSecret);
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
setIsLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
// Verify on load if credentials exist
|
|
useEffect(() => {
|
|
if (!isLoading && clientId && clientSecret) verify();
|
|
}, [isLoading]);
|
|
|
|
const handleSave = async (ev?: FormEvent) => {
|
|
ev?.preventDefault();
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
try {
|
|
await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
|
|
toast.success(t('settings.google.saved'));
|
|
verify();
|
|
} catch {
|
|
toast.error(t('settings.google.saveFailed'));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) return null;
|
|
|
|
const redirectUri = `${window.location.origin}/api/integrations/google/callback`;
|
|
|
|
return (
|
|
<div className="grid gap-5">
|
|
<CredentialStatus status={verifyStatus} isVerifying={isVerifying} />
|
|
|
|
<SetupGuide redirectUri={redirectUri} />
|
|
|
|
<form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientId')}</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
type="text"
|
|
value={clientId}
|
|
onChange={(ev) => setClientId(ev.target.value)}
|
|
placeholder="123456789.apps.googleusercontent.com"
|
|
/>
|
|
</Label>
|
|
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientSecret')}</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
type="password"
|
|
value={clientSecret}
|
|
onChange={(ev) => setClientSecret(ev.target.value)}
|
|
placeholder="GOCSPX-..."
|
|
/>
|
|
</Label>
|
|
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.redirectUri')}</span>
|
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.google.redirectUriHint')}</p>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60"
|
|
type="text"
|
|
value={redirectUri}
|
|
readOnly
|
|
/>
|
|
</Label>
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={isSaving || !clientId.trim() || !clientSecret.trim()}
|
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
|
>
|
|
{isSaving ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|