integrations keyed, and the Google guide's foreign labels marked (163 -> 96)

Applies the rule added to docs/localization.md in 91b9bec9: a label quoted from
another product's interface is an identifier, so the prose around it is translated
and the label itself is left in English and NOT keyed.

Gives that decision a name in the code. <Untranslated reason="foreign-ui-label">
states it at each of the ~30 call sites, where whoever next edits the sentence will
see it, and gives the scanner something exact to skip -- without which labels that
MUST stay English count as debt forever and the area can never be held at zero. That
is the same trap as counting new Error() messages. `reason` is a closed union so a
second excuse has to be argued for rather than smuggled in as free text.

The sentences needed rephrasing, not just splitting. "Go to the [New Project] page."
decomposes into "Go to the" + link + "page." -- and the Portuguese value of that
second key is "." A key that holds a full stop is the fragmentation smell, so the
English now puts the label at a clause boundary ("Open the project creation page:"
then the link), which reads naturally in both languages and needs no node-level
interpolation. Worth knowing for phase 3: sentences with an inline link are the one
shape this catalog format cannot express, and rephrasing is the cheap way out.

Scanner: the existing SCREAMING_CONSTANT rule now allows . and - so Google's
client-secret prefix example GOCSPX-... falls under it. Same rule, wider class.
This commit is contained in:
2026-08-23 05:24:32 +00:00
parent 91b9bec926
commit 5e032898f8
7 changed files with 298 additions and 127 deletions
+15 -2
View File
@@ -80,7 +80,8 @@ function isHuman(value: string | undefined): value is string {
if (!/[A-Za-z]{3}/.test(s)) return false; if (!/[A-Za-z]{3}/.test(s)) return false;
if (/^[a-z0-9\-_./:]+$/.test(s)) return false; if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false; if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.test(s)) return false; // Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
if (/^[A-Z0-9_.-]+$/.test(s)) return false;
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string // `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme // pattern because it starts with a capital and interpolates; excluded here because an auth scheme
// followed by a credential is never something a person reads. // followed by a credential is never something a person reads.
@@ -121,6 +122,18 @@ const stripComments = (source: string): string =>
const stripCodeElements = (source: string): string => const stripCodeElements = (source: string): string =>
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>'); source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
/**
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
* another product's UI, which docs/localization.md classifies as an identifier.
*
* Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
* doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
* carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
* it and say why.
*/
const stripUntranslated = (source: string): string =>
source.replace(/<Untranslated\b[^>]*>[\s\S]*?<\/Untranslated>/g, '<Untranslated/>');
function* walk(dir: string): Generator<string> { function* walk(dir: string): Generator<string> {
let entries: string[]; let entries: string[];
try { try {
@@ -152,7 +165,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
files++; files++;
let source: string; let source: string;
try { try {
source = stripCodeElements(stripComments(readFileSync(file, 'utf8'))); source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
} catch { } catch {
continue; continue;
} }
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type ApifyConfigData = { type ApifyConfigData = {
apiToken: string; apiToken: string;
@@ -15,6 +16,7 @@ type ApifyStatus = {
}; };
export const ApifyConfig = () => { export const ApifyConfig = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -38,10 +40,10 @@ export const ApifyConfig = () => {
setIsSaving(true); setIsSaving(true);
try { try {
await client.put('/integrations/apify/config', { apiToken: apiToken.trim() }); await client.put('/integrations/apify/config', { apiToken: apiToken.trim() });
toast.success('Apify API token saved'); toast.success(t('settings.apify.saved'));
setStatus({ configured: !!apiToken.trim() }); setStatus({ configured: !!apiToken.trim() });
} catch { } catch {
toast.error('Failed to save Apify API token'); toast.error(t('settings.apify.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -64,7 +66,7 @@ export const ApifyConfig = () => {
<form onSubmit={handleSave} className="grid gap-5"> <form onSubmit={handleSave} className="grid gap-5">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Token</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.apify.token')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type GoogleStatus = { type GoogleStatus = {
connected: boolean; connected: boolean;
@@ -11,6 +12,7 @@ type GoogleStatus = {
}; };
export const GoogleAccount = () => { export const GoogleAccount = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<GoogleStatus>({ const [status, setStatus] = useState<GoogleStatus>({
@@ -30,9 +32,9 @@ export const GoogleAccount = () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const result = params.get('google'); const result = params.get('google');
if (result === 'success') { if (result === 'success') {
toast.success('Google account connected'); toast.success(t('settings.googleAccount.connectedToast'));
} else if (result === 'error') { } else if (result === 'error') {
toast.error('Failed to connect Google account'); toast.error(t('settings.googleAccount.connectFailed'));
} }
if (result) { if (result) {
window.history.replaceState({}, '', window.location.pathname); window.history.replaceState({}, '', window.location.pathname);
@@ -51,9 +53,9 @@ export const GoogleAccount = () => {
try { try {
await client.delete('/integrations/google/connection'); await client.delete('/integrations/google/connection');
setStatus({ ...status, connected: false, email: null }); setStatus({ ...status, connected: false, email: null });
toast.success('Google account disconnected'); toast.success(t('settings.googleAccount.disconnectedToast'));
} catch { } catch {
toast.error('Failed to disconnect Google account'); toast.error(t('settings.googleAccount.disconnectFailed'));
} }
}; };
@@ -63,17 +65,17 @@ export const GoogleAccount = () => {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div> <div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Google Account</p> <p className="text-sm font-medium text-duck-dark dark:text-foreground">{t('settings.googleAccount.title')}</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1"> <p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">{t('settings.googleAccount.hint')}</p>
Connect your Google account for Calendar, Contacts, and other Google services.
</p>
</div> </div>
{status.connected ? ( {status.connected ? (
<> <>
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"> <div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-success shrink-0" /> <div className="h-2.5 w-2.5 rounded-full bg-success shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p> <p className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.googleAccount.connected')}
</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p> <p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
</div> </div>
{status.picture && ( {status.picture && (
@@ -81,7 +83,7 @@ export const GoogleAccount = () => {
)} )}
</div> </div>
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer"> <Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
Disconnect {t('settings.googleAccount.disconnect')}
</Button> </Button>
</> </>
) : ( ) : (
@@ -90,7 +92,7 @@ export const GoogleAccount = () => {
onClick={handleConnect} onClick={handleConnect}
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" 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"
> >
Connect Google Account {t('settings.googleAccount.connect')}
</Button> </Button>
)} )}
</div> </div>
@@ -7,197 +7,175 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
import { Untranslated } from '@/lib/Untranslated';
type GoogleOAuthSettings = { type GoogleOAuthSettings = {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
}; };
// The scope strings are Google's own identifiers; only the explanation beside them is ours.
const SCOPES = [ const SCOPES = [
{ scope: 'gmail.readonly', description: 'Read emails' }, { scope: 'gmail.readonly', descriptionKey: 'settings.google.scopeGmail' },
{ scope: 'calendar.readonly', description: 'Read calendar events' }, { scope: 'calendar.readonly', descriptionKey: 'settings.google.scopeCalendar' },
]; ] as const;
const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
const [open, setOpen] = useState(false); 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 ( return (
<Collapsible open={open} onOpenChange={setOpen}> <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"> <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' : ''}`} /> <ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
Step-by-step setup guide {t('settings.google.guideToggle')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5"> <ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step1.title')}</strong>
<p className="mt-1"> <p className="mt-1">
Go to the{' '} {t('settings.google.step1.body')}{' '}
<a <GoogleLink
href="https://console.cloud.google.com/projectcreate" href="https://console.cloud.google.com/projectcreate"
target="_blank" label={t('settings.google.step1.link')}
rel="noopener noreferrer" />{' '}
className="text-duck-teal underline" → <Untranslated reason="foreign-ui-label">Create</Untranslated>
>
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
</p> </p>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step2.title')}</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} {t('settings.google.step2.body')}{' '}
<a <GoogleLink
href="https://console.cloud.google.com/apis/library" href="https://console.cloud.google.com/apis/library"
target="_blank" label={t('settings.google.step2.link')}
rel="noopener noreferrer" />{' '}
className="text-duck-teal underline" → <Untranslated reason="foreign-ui-label">Enable</Untranslated>
>
API Library
</a>
. Search for and enable each of these:
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li> <li>
<strong>Gmail API</strong> <Untranslated reason="foreign-ui-label">Gmail API</Untranslated>
</li> </li>
<li> <li>
<strong>Google Calendar API</strong> <Untranslated reason="foreign-ui-label">Google Calendar API</Untranslated>
</li> </li>
</ul> </ul>
<p className="mt-1">
Click each one, then click <strong>Enable</strong>.
</p>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step3.title')}</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} <GoogleLink
<a
href="https://console.cloud.google.com/auth/branding" href="https://console.cloud.google.com/auth/branding"
target="_blank" label={t('settings.google.step3.link')}
rel="noopener noreferrer" />
className="text-duck-teal underline"
>
OAuth Branding
</a>
.
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li> <li>
Set <strong>App name</strong> to your organization name or &quot;Officer&quot; {t('settings.google.step3.appName')} <Untranslated reason="foreign-ui-label">App name</Untranslated>
</li> </li>
<li> <li>
Set <strong>User support email</strong> to your admin email {t('settings.google.step3.supportEmail')}{' '}
<Untranslated reason="foreign-ui-label">User support email</Untranslated>
</li> </li>
<li> <li>
Add your admin email under <strong>Developer contact information</strong> {t('settings.google.step3.devContact')}{' '}
<Untranslated reason="foreign-ui-label">Developer contact information</Untranslated>
</li> </li>
<li> <li>
Click <strong>Save</strong> {t('settings.google.step3.save')} <Untranslated reason="foreign-ui-label">Save</Untranslated>
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step4.title')}</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} <GoogleLink
<a
href="https://console.cloud.google.com/auth/audience" href="https://console.cloud.google.com/auth/audience"
target="_blank" label={t('settings.google.step4.link')}
rel="noopener noreferrer" />
className="text-duck-teal underline"
>
OAuth Audience
</a>
.
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li> <li>
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed {t('settings.google.step4.internal')} <Untranslated reason="foreign-ui-label">Internal</Untranslated>
</li> </li>
<li> <li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '} {t('settings.google.step4.external')} <Untranslated reason="foreign-ui-label">External</Untranslated>
(required while the app is unverified; limit of 100 test users)
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step5.title')}</strong>
<p className="mt-1"> <p className="mt-1">
In the left sidebar, click{' '} {t('settings.google.step5.body')}{' '}
<a <GoogleLink href="https://console.cloud.google.com/auth/scopes" label={t('settings.google.step5.link')} />{' '}
href="https://console.cloud.google.com/auth/scopes" → <Untranslated reason="foreign-ui-label">Add or remove scopes</Untranslated>
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => ( {SCOPES.map((entry) => (
<li key={s.scope}> <li key={entry.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '} <code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{s.description} {entry.scope}
</code>{' '}
— {t(entry.descriptionKey)}
</li> </li>
))} ))}
</ul> </ul>
<p className="mt-1"> <p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">{t('settings.google.step5.note')}</p>
Click <strong>Update</strong>, then <strong>Save</strong>.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step6.title')}</strong>
<p className="mt-1"> <p className="mt-1">
In the left sidebar, click{' '} {t('settings.google.step6.body')}{' '}
<a <GoogleLink
href="https://console.cloud.google.com/auth/clients" href="https://console.cloud.google.com/auth/clients"
target="_blank" label={t('settings.google.step6.link')}
rel="noopener noreferrer" />{' '}
className="text-duck-teal underline" → <Untranslated reason="foreign-ui-label">Create OAuth client</Untranslated>
>
Clients
</a>
, then click <strong>Create OAuth client</strong>.
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li> <li>
Application type: <strong>Web application</strong> {t('settings.google.step6.appType')}{' '}
<Untranslated reason="foreign-ui-label">Web application</Untranslated>
</li> </li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li> <li>{t('settings.google.step6.nameAnything')}</li>
<li> <li>
Authorized redirect URIs: add{' '} {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"> <code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all">
{redirectUri} {redirectUri}
</code> </code>
</li> </li>
<li> <li>
Click <strong>Create</strong> {t('settings.google.step6.thenClick')} <Untranslated reason="foreign-ui-label">Create</Untranslated>
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step7.title')}</strong>
<p className="mt-1"> <p className="mt-1">{t('settings.google.step7.body')}</p>
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
them into the fields below.
</p>
</li> </li>
</ol> </ol>
</CollapsibleContent> </CollapsibleContent>
@@ -208,11 +186,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
type VerifyStatus = { valid: boolean; error: string | null } | null; type VerifyStatus = { valid: boolean; error: string | null } | null;
const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => { const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
const { t } = useTranslation();
if (isVerifying) { if (isVerifying) {
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"> <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" /> <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">Verifying credentials...</span> <span className="text-sm text-duck-dark/50 dark:text-foreground/50">{t('settings.google.verifying')}</span>
</div> </div>
); );
} }
@@ -223,13 +202,14 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"> <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'}`} /> <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'}`}> <span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')} {status.valid ? t('settings.google.valid') : (status.error ?? t('settings.google.invalid'))}
</span> </span>
</div> </div>
); );
}; };
export const GoogleOAuthConfig = () => { export const GoogleOAuthConfig = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -243,7 +223,7 @@ export const GoogleOAuthConfig = () => {
client client
.get<{ valid: boolean; error: string | null }>('/integrations/google/verify') .get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
.then(setVerifyStatus) .then(setVerifyStatus)
.catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' })) .catch(() => setVerifyStatus({ valid: false, error: t('settings.google.verifyFailed') }))
.finally(() => setIsVerifying(false)); .finally(() => setIsVerifying(false));
}; };
@@ -273,10 +253,10 @@ export const GoogleOAuthConfig = () => {
setIsSaving(true); setIsSaving(true);
try { try {
await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() }); await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
toast.success('Google OAuth configuration saved'); toast.success(t('settings.google.saved'));
verify(); verify();
} catch { } catch {
toast.error('Failed to save Google OAuth configuration'); toast.error(t('settings.google.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -294,7 +274,7 @@ export const GoogleOAuthConfig = () => {
<form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5"> <form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientId')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text" type="text"
@@ -305,7 +285,7 @@ export const GoogleOAuthConfig = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client Secret</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientSecret')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
@@ -316,10 +296,8 @@ export const GoogleOAuthConfig = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Redirect URI</span> <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"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.google.redirectUriHint')}</p>
Add this URI to your Google OAuth client&apos;s authorized redirect URIs
</p>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60"
type="text" type="text"
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
type UntranslatedProps = {
children: ReactNode;
/** Why this text is not localized. Required so the decision is legible at the call site. */
reason: 'foreign-ui-label';
className?: string;
};
/**
* Text that is deliberately NOT localized, marked as such where it is written.
*
* The only reason so far is `foreign-ui-label`: a label quoted from another product's interface, which
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
* what the reader sees on Google's screen, and Google localises by their Google account language rather
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
*
* It exists as a component rather than a comment for two reasons. It states the decision at every call
* site, where the next person editing the sentence will see it. And it gives the scanner something exact
* to skip: without that, ~40 labels that MUST stay English count as debt forever, and the area can never
* be held at zero — the same trap as counting `new Error()` messages.
*
* `reason` is a closed union on purpose. A second legitimate reason should be argued for and added here,
* not smuggled in as free text.
*/
export const Untranslated = ({ children, className }: UntranslatedProps) => (
<strong className={className}>{children}</strong>
);
+74
View File
@@ -375,6 +375,80 @@
"deleteFailed": "Could not delete", "deleteFailed": "Could not delete",
"copyField": "Copy {{field}}", "copyField": "Copy {{field}}",
"copied": "{{what}} copied" "copied": "{{what}} copied"
},
"google": {
"guideToggle": "Step-by-step setup guide",
"step1": {
"title": "Create a Google Cloud project",
"body": "Open the project creation page, give it a name (for example “Officer”), and click:",
"link": "New Project"
},
"step2": {
"title": "Enable the APIs",
"body": "Open the API library and search for each of these, then click:",
"link": "API Library"
},
"step3": {
"title": "Configure the OAuth consent screen",
"link": "OAuth Branding",
"appName": "Set your organization name, or “Officer”, as:",
"supportEmail": "Set your admin email as:",
"devContact": "Add your admin email under:",
"save": "Then click:"
},
"step4": {
"title": "Set the audience",
"link": "OAuth Audience",
"internal": "If your team uses Google Workspace, choose — no verification needed:",
"external": "Otherwise choose, and add your team’s emails under Test users (required while the app is unverified; limit of 100 test users):"
},
"step5": {
"title": "Add scopes",
"link": "Data Access",
"body": "Open data access and search for and add the scopes below, then click:",
"note": "Google classifies the calendar scope as sensitive and the Gmail scope as restricted. That is fine for Internal apps (Google Workspace) and for External apps in testing mode; publishing to production with restricted scopes requires Google verification."
},
"step6": {
"title": "Create OAuth credentials",
"link": "Clients",
"body": "Open the clients page and click:",
"appType": "Application type:",
"nameAnything": "Name: anything (for example “Officer”)",
"redirectUris": "Authorized redirect URIs — add:",
"thenClick": "Then click:"
},
"step7": {
"title": "Copy the credentials",
"body": "A dialog shows your client ID and client secret. Copy both and paste them into the fields below."
},
"scopeGmail": "Read emails",
"scopeCalendar": "Read calendar events",
"verifying": "Verifying credentials…",
"valid": "Credentials valid",
"invalid": "Invalid credentials",
"verifyFailed": "Verification request failed",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"redirectUri": "Redirect URI",
"redirectUriHint": "Add this URI to your Google OAuth client’s authorized redirect URIs",
"saved": "Google OAuth configuration saved",
"saveFailed": "Failed to save Google OAuth configuration"
},
"googleAccount": {
"title": "Google Account",
"hint": "Connect your Google account for Calendar, Contacts, and other Google services.",
"connected": "Connected",
"disconnect": "Disconnect",
"connect": "Connect Google Account",
"connectedToast": "Google account connected",
"connectFailed": "Failed to connect Google account",
"disconnectedToast": "Google account disconnected",
"disconnectFailed": "Failed to disconnect Google account"
},
"apify": {
"token": "API Token",
"saved": "Apify API token saved",
"saveFailed": "Failed to save Apify API token"
} }
}, },
"header": { "header": {
+74
View File
@@ -375,6 +375,80 @@
"deleteFailed": "Não foi possível eliminar", "deleteFailed": "Não foi possível eliminar",
"copyField": "Copiar {{field}}", "copyField": "Copiar {{field}}",
"copied": "{{what}} copiado" "copied": "{{what}} copiado"
},
"google": {
"guideToggle": "Guia de configuração passo a passo",
"step1": {
"title": "Criar um projeto Google Cloud",
"body": "Abra a página de criação de projeto, dê-lhe um nome (por exemplo “Officer”) e clique em:",
"link": "New Project"
},
"step2": {
"title": "Ativar as APIs",
"body": "Abra a biblioteca de APIs, procure cada uma destas e clique em:",
"link": "API Library"
},
"step3": {
"title": "Configurar o ecrã de consentimento OAuth",
"link": "OAuth Branding",
"appName": "Defina o nome da sua organização, ou “Officer”, como:",
"supportEmail": "Defina o email do administrador como:",
"devContact": "Adicione o email do administrador em:",
"save": "Depois clique em:"
},
"step4": {
"title": "Definir o público",
"link": "OAuth Audience",
"internal": "Se a sua equipa usa Google Workspace, escolha — não é necessária verificação:",
"external": "Caso contrário escolha, e adicione os emails da sua equipa em Test users (obrigatório enquanto a aplicação não estiver verificada; limite de 100 utilizadores de teste):"
},
"step5": {
"title": "Adicionar âmbitos",
"link": "Data Access",
"body": "Abra o acesso a dados, procure e adicione os âmbitos abaixo e depois clique em:",
"note": "A Google classifica o âmbito do calendário como sensível e o do Gmail como restrito. Isso não é problema para aplicações Internal (Google Workspace) nem para aplicações External em modo de teste; publicar em produção com âmbitos restritos exige verificação pela Google."
},
"step6": {
"title": "Criar credenciais OAuth",
"link": "Clients",
"body": "Abra a página de clientes e clique em:",
"appType": "Tipo de aplicação:",
"nameAnything": "Nome: qualquer um (por exemplo “Officer”)",
"redirectUris": "URIs de redirecionamento autorizados — adicione:",
"thenClick": "Depois clique em:"
},
"step7": {
"title": "Copiar as credenciais",
"body": "Uma caixa de diálogo mostra o seu client ID e client secret. Copie ambos e cole-os nos campos abaixo."
},
"scopeGmail": "Ler emails",
"scopeCalendar": "Ler eventos do calendário",
"verifying": "A verificar credenciais…",
"valid": "Credenciais válidas",
"invalid": "Credenciais inválidas",
"verifyFailed": "O pedido de verificação falhou",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"redirectUri": "URI de redirecionamento",
"redirectUriHint": "Adicione este URI aos URIs de redirecionamento autorizados do seu cliente OAuth da Google",
"saved": "Configuração OAuth da Google guardada",
"saveFailed": "Falha ao guardar a configuração OAuth da Google"
},
"googleAccount": {
"title": "Conta Google",
"hint": "Ligue a sua conta Google para o Calendário, Contactos e outros serviços Google.",
"connected": "Ligada",
"disconnect": "Desligar",
"connect": "Ligar conta Google",
"connectedToast": "Conta Google ligada",
"connectFailed": "Falha ao ligar a conta Google",
"disconnectedToast": "Conta Google desligada",
"disconnectFailed": "Falha ao desligar a conta Google"
},
"apify": {
"token": "Token de API",
"saved": "Token de API do Apify guardado",
"saveFailed": "Falha ao guardar o token de API do Apify"
} }
}, },
"header": { "header": {