Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleOAuthConfig.tsx
T
pastilhas 7899539d4b add the product-name reason that should have been in 43197745
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.
2026-08-23 14:50:06 +00:00

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>
);
};