diff --git a/docs/superpowers/specs/2026-07-13-i18n-full-rollout-design.md b/docs/superpowers/specs/2026-07-13-i18n-full-rollout-design.md index 5d7d417a..c83be9c6 100644 --- a/docs/superpowers/specs/2026-07-13-i18n-full-rollout-design.md +++ b/docs/superpowers/specs/2026-07-13-i18n-full-rollout-design.md @@ -112,7 +112,7 @@ grün, en/de gepflegt, Build grün, Browser-Check ok). | S8 | `reports` | ✅ | ReportsPage localized (new `reports` ns); burndown + velocity section titles/empty-states + recharts legend series labels via `name` | | S9 | `notifications` | ✅ | NotificationBell (aria) + NotificationsPage localized (new `notifications` ns); scanner-blind `typeLabel` switch → keyed `t()`; hand-rolled `formatTime` → `formatRelativeTime` (relative-time rollout) | | S10 | `projects` | ✅ | ProjectCreatePage localized (new `projects` ns); placeholders routed through `t()`; ProjectListPage already string-free (not allowlisted) | -| S11 | `project-settings` | ⬜ | MembersPage, LabelsPage, VersionsPage, CustomFieldsPage, ProjectAuditPage, OrganizationSettingsPage, SettingsLayout (Rest) | +| S11 | `project-settings` | ✅ | All 7 files localized (new `project-settings` ns); scanner-blind ROLE_LABELS/FIELD_TYPES/audit column headers/settings-nav labels localized at render site; members org banner via ``; audit timestamp via `formatDateTime`; CustomFields `t`-shadow (`ft`) handled | | S12 | `workflow` | ⬜ | WorkflowEditorPage, WorkflowCanvas, StatusNode, TransitionArrow, TransitionGuardPanel | | S13 | `automation` | ⬜ | AutomationPage, AutomationRuleEditorPage, AdminAutomationPage, RuleEditor, ActionList, ActionRow, ConditionGroupBuilder, ConditionRow, TriggerSelector | | S14 | `admin` | ⬜ | AdminUsersPage, AuditLogPage, SsoSettingsPage | diff --git a/frontend/scripts/i18n-allowlist.json b/frontend/scripts/i18n-allowlist.json index 0b03fe67..ea8c07f8 100644 --- a/frontend/scripts/i18n-allowlist.json +++ b/frontend/scripts/i18n-allowlist.json @@ -8,7 +8,6 @@ "src/components/automation/TriggerSelector.tsx", "src/components/workflow/TransitionGuardPanel.tsx", "src/layouts/AuthLayout.tsx", - "src/layouts/SettingsLayout.tsx", "src/pages/admin/AdminAutomationPage.tsx", "src/pages/admin/AdminUsersPage.tsx", "src/pages/admin/AuditLogPage.tsx", @@ -19,12 +18,6 @@ "src/pages/orgs/OrgsPage.tsx", "src/pages/projects/servicedesk/IncidentDashboardPage.tsx", "src/pages/projects/servicedesk/ServiceDeskPage.tsx", - "src/pages/projects/settings/CustomFieldsPage.tsx", - "src/pages/projects/settings/LabelsPage.tsx", - "src/pages/projects/settings/MembersPage.tsx", - "src/pages/projects/settings/OrganizationSettingsPage.tsx", - "src/pages/projects/settings/ProjectAuditPage.tsx", - "src/pages/projects/settings/VersionsPage.tsx", "src/pages/settings/AccessTokensPage.tsx", "src/pages/settings/ApiKeysPage.tsx", "src/pages/settings/IntegrationsPage.tsx", diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 94f0f5a9..c2b9d5b3 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -30,6 +30,8 @@ import enNotifications from './locales/en/notifications.json' import deNotifications from './locales/de/notifications.json' import enProjects from './locales/en/projects.json' import deProjects from './locales/de/projects.json' +import enProjectSettings from './locales/en/project-settings.json' +import deProjectSettings from './locales/de/project-settings.json' export const SUPPORTED_LANGUAGES = ['en', 'de'] as const export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number] @@ -39,14 +41,14 @@ i18n .use(initReactI18next) .init({ resources: { - en: { common: enCommon, settings: enSettings, nav: enNav, auth: enAuth, issues: enIssues, 'issues-fields': enIssuesFields, comments: enComments, board: enBoard, backlog: enBacklog, sprints: enSprints, dashboard: enDashboard, reports: enReports, notifications: enNotifications, projects: enProjects }, - de: { common: deCommon, settings: deSettings, nav: deNav, auth: deAuth, issues: deIssues, 'issues-fields': deIssuesFields, comments: deComments, board: deBoard, backlog: deBacklog, sprints: deSprints, dashboard: deDashboard, reports: deReports, notifications: deNotifications, projects: deProjects }, + en: { common: enCommon, settings: enSettings, nav: enNav, auth: enAuth, issues: enIssues, 'issues-fields': enIssuesFields, comments: enComments, board: enBoard, backlog: enBacklog, sprints: enSprints, dashboard: enDashboard, reports: enReports, notifications: enNotifications, projects: enProjects, 'project-settings': enProjectSettings }, + de: { common: deCommon, settings: deSettings, nav: deNav, auth: deAuth, issues: deIssues, 'issues-fields': deIssuesFields, comments: deComments, board: deBoard, backlog: deBacklog, sprints: deSprints, dashboard: deDashboard, reports: deReports, notifications: deNotifications, projects: deProjects, 'project-settings': deProjectSettings }, }, fallbackLng: 'en', supportedLngs: [...SUPPORTED_LANGUAGES], nonExplicitSupportedLngs: true, defaultNS: 'common', - ns: ['common', 'settings', 'nav', 'auth', 'issues', 'issues-fields', 'comments', 'board', 'backlog', 'sprints', 'dashboard', 'reports', 'notifications', 'projects'], + ns: ['common', 'settings', 'nav', 'auth', 'issues', 'issues-fields', 'comments', 'board', 'backlog', 'sprints', 'dashboard', 'reports', 'notifications', 'projects', 'project-settings'], interpolation: { escapeValue: false }, detection: { order: ['localStorage', 'navigator'], diff --git a/frontend/src/i18n/locales/de/project-settings.json b/frontend/src/i18n/locales/de/project-settings.json new file mode 100644 index 00000000..5f646ce8 --- /dev/null +++ b/frontend/src/i18n/locales/de/project-settings.json @@ -0,0 +1,99 @@ +{ + "form": { + "name": "Name", + "create": "Erstellen", + "edit": "Bearbeiten", + "delete": "Löschen", + "nameRequired": "Name ist erforderlich", + "maxChars": "Maximal 50 Zeichen" + }, + "members": { + "title": "Mitglieder", + "noPermission": "Du hast keine Berechtigung, die Mitglieder dieses Projekts zu verwalten.", + "orgBanner": "Dieses Projekt gehört zu {{org}}. Deren Eigentümer und Admins sind hier Admins; deren Mitglieder sind Betrachter. Diese Personen haben Zugriff, ohne in der Liste unten aufzutauchen.", + "orgFallback": "einer Organisation", + "add": "Mitglied hinzufügen", + "searchPlaceholder": "Nach Name oder E-Mail suchen…", + "addButton": "Hinzufügen", + "remove": "Entfernen", + "owner": "Eigentümer", + "you": "Du", + "removeConfirm": "{{name}} aus diesem Projekt entfernen?", + "alreadyMember": "Dieser Benutzer ist bereits Mitglied.", + "addFailed": "Mitglied konnte nicht hinzugefügt werden.", + "role": { + "VIEWER": "Nur Lesen", + "MEMBER": "Lesen & Schreiben", + "ADMIN": "Admin" + } + }, + "labels": { + "title": "Labels", + "new": "Neues Label", + "namePlaceholder": "Label-Name", + "color": "Farbe", + "preview": "Vorschau", + "previewName": "Vorschau", + "duplicate": "Ein Label mit diesem Namen existiert bereits.", + "deleteConfirm": "Dieses Label löschen? Es wird von allen Vorgängen entfernt.", + "empty": "Noch keine Labels. Erstelle dein erstes!" + }, + "versions": { + "title": "Versionen", + "new": "Neue Version", + "namePlaceholder": "z. B. v1.0.0", + "duplicate": "Eine Version mit diesem Namen existiert bereits.", + "deleteConfirm": "Diese Version löschen? Sie wird von allen Vorgängen entfernt.", + "empty": "Noch keine Versionen. Erstelle deine erste!" + }, + "fields": { + "title": "Benutzerdefinierte Felder", + "new": "Neues Feld", + "namePlaceholder": "Feldname", + "requiredLabel": "Pflichtfeld", + "requiredBadge": "Pflicht", + "duplicate": "Ein Feld mit diesem Namen existiert bereits.", + "deleteConfirm": "Dieses Feld löschen? Alle Werte gehen verloren.", + "empty": "Noch keine benutzerdefinierten Felder.", + "showOptions": "Optionen", + "hideOptions": "Optionen ausblenden", + "optionPlaceholder": "Neue Options-Bezeichnung", + "add": "Hinzufügen", + "type": { + "TEXT": "Text", + "NUMBER": "Zahl", + "DATE": "Datum", + "DROPDOWN": "Auswahlliste", + "CHECKBOX": "Kontrollkästchen" + } + }, + "audit": { + "title": "Audit-Protokoll", + "filterPlaceholder": "Aktion filtern…", + "empty": "Keine Audit-Ereignisse", + "col": { + "time": "Zeit", + "user": "Benutzer", + "action": "Aktion", + "level": "Stufe", + "resource": "Ressource" + }, + "level": { + "SECURITY": "Sicherheit", + "WRITE": "Schreiben", + "ALL": "Alle" + } + }, + "org": { + "title": "Organisation", + "noPermission": "Du hast keine Berechtigung, die Organisation dieses Projekts zu verwalten.", + "current": "Aktuelle Organisation", + "notAssigned": "Nicht zugewiesen", + "assignLabel": "Einer Organisation zuweisen", + "selectPlaceholder": "Organisation auswählen…", + "assign": "Zuweisen", + "removeFromOrg": "Aus Organisation entfernen", + "forbidden": "Du musst Eigentümer oder Admin der Zielorganisation sein, um dieses Projekt zuzuweisen.", + "updateFailed": "Organisation konnte nicht aktualisiert werden." + } +} diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index 5270c56d..3c99c612 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -18,6 +18,9 @@ "changing": "Wird geändert…", "changePassword": "Passwort ändern" }, + "tokens": { + "title": "Zugriffstokens" + }, "account": { "title": "Konto", "deleteAccount": "Konto löschen", diff --git a/frontend/src/i18n/locales/en/project-settings.json b/frontend/src/i18n/locales/en/project-settings.json new file mode 100644 index 00000000..c09eebcd --- /dev/null +++ b/frontend/src/i18n/locales/en/project-settings.json @@ -0,0 +1,99 @@ +{ + "form": { + "name": "Name", + "create": "Create", + "edit": "Edit", + "delete": "Delete", + "nameRequired": "Name is required", + "maxChars": "Max 50 characters" + }, + "members": { + "title": "Members", + "noPermission": "You don’t have permission to manage members for this project.", + "orgBanner": "This project belongs to {{org}}. Its owners and admins are admins here; its members are viewers. Those people have access without appearing in the list below.", + "orgFallback": "an organization", + "add": "Add member", + "searchPlaceholder": "Search by name or email…", + "addButton": "Add", + "remove": "Remove", + "owner": "Owner", + "you": "You", + "removeConfirm": "Remove {{name}} from this project?", + "alreadyMember": "This user is already a member.", + "addFailed": "Could not add member.", + "role": { + "VIEWER": "Read-only", + "MEMBER": "Read & Write", + "ADMIN": "Admin" + } + }, + "labels": { + "title": "Labels", + "new": "New Label", + "namePlaceholder": "Label name", + "color": "Color", + "preview": "Preview", + "previewName": "Preview", + "duplicate": "A label with that name already exists.", + "deleteConfirm": "Delete this label? It will be removed from all issues.", + "empty": "No labels yet. Create your first one!" + }, + "versions": { + "title": "Versions", + "new": "New Version", + "namePlaceholder": "e.g. v1.0.0", + "duplicate": "A version with that name already exists.", + "deleteConfirm": "Delete this version? It will be removed from all issues.", + "empty": "No versions yet. Create your first one!" + }, + "fields": { + "title": "Custom Fields", + "new": "New Field", + "namePlaceholder": "Field name", + "requiredLabel": "Required", + "requiredBadge": "required", + "duplicate": "A field with that name already exists.", + "deleteConfirm": "Delete this field? All values will be lost.", + "empty": "No custom fields yet.", + "showOptions": "Options", + "hideOptions": "Hide options", + "optionPlaceholder": "New option label", + "add": "Add", + "type": { + "TEXT": "Text", + "NUMBER": "Number", + "DATE": "Date", + "DROPDOWN": "Dropdown", + "CHECKBOX": "Checkbox" + } + }, + "audit": { + "title": "Audit Log", + "filterPlaceholder": "Filter action…", + "empty": "No audit events", + "col": { + "time": "Time", + "user": "User", + "action": "Action", + "level": "Level", + "resource": "Resource" + }, + "level": { + "SECURITY": "Security", + "WRITE": "Write", + "ALL": "All" + } + }, + "org": { + "title": "Organization", + "noPermission": "You don't have permission to manage this project's organization.", + "current": "Current organization", + "notAssigned": "Not assigned", + "assignLabel": "Assign to organization", + "selectPlaceholder": "Select an organization…", + "assign": "Assign", + "removeFromOrg": "Remove from organization", + "forbidden": "You must be an owner or admin of the target organization to assign this project to it.", + "updateFailed": "Could not update the organization." + } +} diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index f98f3287..9422afc8 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -18,6 +18,9 @@ "changing": "Changing…", "changePassword": "Change password" }, + "tokens": { + "title": "Access Tokens" + }, "account": { "title": "Account", "deleteAccount": "Delete account", diff --git a/frontend/src/layouts/SettingsLayout.tsx b/frontend/src/layouts/SettingsLayout.tsx index 6a386ed2..0166fe79 100644 --- a/frontend/src/layouts/SettingsLayout.tsx +++ b/frontend/src/layouts/SettingsLayout.tsx @@ -1,20 +1,22 @@ import { Outlet, NavLink } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { User, Shield, Bell, KeyRound, UserX } from 'lucide-react' const items = [ - { to: '/settings/profile', label: 'Profile', icon: User }, - { to: '/settings/security', label: 'Security', icon: Shield }, - { to: '/settings/notifications', label: 'Notifications', icon: Bell }, - { to: '/settings/tokens', label: 'Access Tokens', icon: KeyRound }, - { to: '/settings/account', label: 'Account', icon: UserX }, + { to: '/settings/profile', labelKey: 'profile.title', icon: User }, + { to: '/settings/security', labelKey: 'security.title', icon: Shield }, + { to: '/settings/notifications', labelKey: 'notifications.title', icon: Bell }, + { to: '/settings/tokens', labelKey: 'tokens.title', icon: KeyRound }, + { to: '/settings/account', labelKey: 'account.title', icon: UserX }, ] export function SettingsLayout() { + const { t } = useTranslation('settings') return (
diff --git a/frontend/src/pages/projects/settings/CustomFieldsPage.tsx b/frontend/src/pages/projects/settings/CustomFieldsPage.tsx index 7e044aae..5153d21c 100644 --- a/frontend/src/pages/projects/settings/CustomFieldsPage.tsx +++ b/frontend/src/pages/projects/settings/CustomFieldsPage.tsx @@ -11,6 +11,7 @@ import { } from '@/hooks/useCustomFields' import { useProjectRole } from '@/hooks/useProjectRole' import type { CustomFieldDefinition } from '@/types' +import { useTranslation } from 'react-i18next' const FIELD_TYPES = ['TEXT', 'NUMBER', 'DATE', 'DROPDOWN', 'CHECKBOX'] as const @@ -25,6 +26,7 @@ function SortableField({ onEdit: () => void onDelete: () => void }) { + const { t } = useTranslation('project-settings') const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: field.id }) const style = { transform: CSS.Transform.toString(transform), transition } @@ -35,12 +37,12 @@ function SortableField({ )} {field.name} - {field.type} - {field.required && required} + {t(`fields.type.${field.type}`)} + {field.required && {t('fields.requiredBadge')}} {canWrite && ( <> - - + + )}
@@ -48,6 +50,7 @@ function SortableField({ } export function CustomFieldsPage() { + const { t } = useTranslation('project-settings') const { key } = useParams<{ key: string }>() const { canWrite } = useProjectRole(key!) const { data: fields = [], isLoading } = useCustomFields(key!) @@ -76,7 +79,7 @@ export function CustomFieldsPage() { setNewRequired(false) setApiError('') } catch { - setApiError('A field with that name already exists.') + setApiError(t('fields.duplicate')) } } @@ -88,12 +91,12 @@ export function CustomFieldsPage() { setEditingField(null) setApiError('') } catch { - setApiError('A field with that name already exists.') + setApiError(t('fields.duplicate')) } } async function handleDelete(id: string) { - if (!confirm('Delete this field? All values will be lost.')) return + if (!confirm(t('fields.deleteConfirm'))) return await deleteField.mutateAsync(id) } @@ -113,16 +116,16 @@ export function CustomFieldsPage() { setApiError('') } - if (isLoading) return
Loading…
+ if (isLoading) return
{t('common:loading')}
return (
-

Custom Fields

+

{t('fields.title')}

{canWrite && !showCreate && !editingField && ( )}
@@ -132,20 +135,20 @@ export function CustomFieldsPage() { {showCreate && (
- setNewName(e.target.value)} placeholder="Field name" autoFocus + setNewName(e.target.value)} placeholder={t('fields.namePlaceholder')} autoFocus className="flex-1 bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm text-white outline-none focus:border-blue-500" />
- - + +
)} @@ -161,11 +164,11 @@ export function CustomFieldsPage() { className="bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm text-white outline-none focus:border-blue-500" />
- - + +
) : ( @@ -187,7 +190,7 @@ export function CustomFieldsPage() {
))} {fields.length === 0 && !showCreate && ( -

No custom fields yet.

+

{t('fields.empty')}

)} @@ -207,6 +210,7 @@ function DropdownOptionsPanel({ newOptionLabel: string onNewOptionLabelChange: (label: string) => void }) { + const { t } = useTranslation('project-settings') const createOption = useCreateOption(projectKey, field.id) const deleteOption = useDeleteOption(projectKey, field.id) @@ -220,7 +224,7 @@ function DropdownOptionsPanel({ return (
{expanded && (
@@ -235,8 +239,8 @@ function DropdownOptionsPanel({ {canWrite && (
onNewOptionLabelChange(e.target.value)} - placeholder="New option label" className="flex-1 bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs text-white outline-none focus:border-blue-500" /> - + placeholder={t('fields.optionPlaceholder')} className="flex-1 bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs text-white outline-none focus:border-blue-500" /> +
)}
diff --git a/frontend/src/pages/projects/settings/LabelsPage.tsx b/frontend/src/pages/projects/settings/LabelsPage.tsx index 17781979..38756181 100644 --- a/frontend/src/pages/projects/settings/LabelsPage.tsx +++ b/frontend/src/pages/projects/settings/LabelsPage.tsx @@ -4,6 +4,7 @@ import { useLabels, useCreateLabel, useUpdateLabel, useDeleteLabel } from '@/hoo import { useProjectRole } from '@/hooks/useProjectRole' import { LabelChip } from '@/components/issue/LabelChip' import type { Label } from '@/types' +import { useTranslation } from 'react-i18next' const PALETTE = [ '#e11d48','#f97316','#eab308','#22c55e', @@ -20,33 +21,34 @@ function LabelForm({ onSubmit: (name: string, color: string) => void onCancel: () => void }) { + const { t } = useTranslation('project-settings') const [name, setName] = useState(initial?.name ?? '') const [color, setColor] = useState(initial?.color ?? PALETTE[0]) const [error, setError] = useState('') function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (!name.trim()) { setError('Name is required'); return } - if (name.trim().length > 50) { setError('Max 50 characters'); return } + if (!name.trim()) { setError(t('form.nameRequired')); return } + if (name.trim().length > 50) { setError(t('form.maxChars')); return } onSubmit(name.trim(), color) } return (
- + { setName(e.target.value); setError('') }} maxLength={50} - placeholder="Label name" + placeholder={t('labels.namePlaceholder')} autoFocus className="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm text-white outline-none focus:border-blue-500" /> {error &&

{error}

}
- +
{PALETTE.map(c => (
- - + +
@@ -76,6 +78,7 @@ function LabelForm({ } export function LabelsPage() { + const { t } = useTranslation('project-settings') const { key } = useParams<{ key: string }>() const { canWrite } = useProjectRole(key!) const { data: labels = [], isLoading } = useLabels(key!) @@ -93,7 +96,7 @@ export function LabelsPage() { setShowCreate(false) setApiError('') } catch { - setApiError('A label with that name already exists.') + setApiError(t('labels.duplicate')) } } @@ -104,27 +107,27 @@ export function LabelsPage() { setEditing(null) setApiError('') } catch { - setApiError('A label with that name already exists.') + setApiError(t('labels.duplicate')) } } async function handleDelete(id: string) { - if (!confirm('Delete this label? It will be removed from all issues.')) return + if (!confirm(t('labels.deleteConfirm'))) return await deleteLabel.mutateAsync(id) } - if (isLoading) return
Loading…
+ if (isLoading) return
{t('common:loading')}
return (
-

Labels

+

{t('labels.title')}

{canWrite && !showCreate && ( )}
@@ -154,13 +157,13 @@ export function LabelsPage() { onClick={() => setEditing(label)} className="text-xs text-gray-400 hover:text-white px-2 py-1 rounded hover:bg-gray-700" > - Edit + {t('form.edit')}
)} @@ -169,7 +172,7 @@ export function LabelsPage() {
))} {labels.length === 0 && !showCreate && ( -

No labels yet. Create your first one!

+

{t('labels.empty')}

)}
diff --git a/frontend/src/pages/projects/settings/MembersPage.tsx b/frontend/src/pages/projects/settings/MembersPage.tsx index a321c2ec..2603bb71 100644 --- a/frontend/src/pages/projects/settings/MembersPage.tsx +++ b/frontend/src/pages/projects/settings/MembersPage.tsx @@ -11,16 +11,13 @@ import { import { useUserSearch } from '@/hooks/useUserSearch' import { useMe } from '@/hooks/useAuth' import { organizationsApi } from '@/api/organizations' +import { Trans, useTranslation } from 'react-i18next' import type { ProjectRole, UserSearchResult } from '@/types' -const ROLE_LABELS: Record = { - VIEWER: 'Read-only', - MEMBER: 'Read & Write', - ADMIN: 'Admin', -} const ROLE_OPTIONS: ProjectRole[] = ['VIEWER', 'MEMBER', 'ADMIN'] function AddMemberForm({ projectKey }: { projectKey: string }) { + const { t } = useTranslation('project-settings') const [input, setInput] = useState('') const [debounced, setDebounced] = useState('') const [selected, setSelected] = useState(null) @@ -44,18 +41,18 @@ function AddMemberForm({ projectKey }: { projectKey: string }) { setInput(''); setDebounced(''); setSelected(null); setRole('MEMBER'); setError('') } catch (e: unknown) { const status = (e as { response?: { status?: number } }).response?.status - setError(status === 409 ? 'This user is already a member.' : 'Could not add member.') + setError(status === 409 ? t('members.alreadyMember') : t('members.addFailed')) } } return (
- + { setSelected(null); setInput(e.target.value); setError('') }} - placeholder="Search by name or email…" + placeholder={t('members.searchPlaceholder')} className="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm text-white outline-none focus:border-blue-500" /> {showDropdown && ( @@ -81,7 +78,7 @@ function AddMemberForm({ projectKey }: { projectKey: string }) { onChange={e => setRole(e.target.value as ProjectRole)} className="bg-gray-700 border border-gray-600 rounded px-2 py-1.5 text-sm text-white" > - {ROLE_OPTIONS.map(r => )} + {ROLE_OPTIONS.map(r => )}
{error &&

{error}

} @@ -98,6 +95,7 @@ function AddMemberForm({ projectKey }: { projectKey: string }) { } export function MembersPage() { + const { t } = useTranslation('project-settings') const { key } = useParams<{ key: string }>() const { data: project } = useProject(key!) const { data: me } = useMe() @@ -110,26 +108,29 @@ export function MembersPage() { enabled: !!project?.orgId, }) - if (isLoading || !project) return
Loading…
+ if (isLoading || !project) return
{t('common:loading')}
if (project.myRole !== 'ADMIN') { - return
You don’t have permission to manage members for this project.
+ return
{t('members.noPermission')}
} async function handleRemove(userId: string, name: string) { - if (!confirm(`Remove ${name} from this project?`)) return + if (!confirm(t('members.removeConfirm', { name }))) return await removeMember.mutateAsync(userId) } return (
-

Members

+

{t('members.title')}

{project.orgId && (
- This project belongs to {org?.name ?? 'an organization'}. - Its owners and admins are admins here; its members are viewers. Those people have access - without appearing in the list below. + }} + />
)} @@ -146,10 +147,10 @@ export function MembersPage() {
{user.email}
{isOwner && ( - Owner + {t('members.owner')} )} {isSelf && !isOwner && ( - You + {t('members.you')} )}
diff --git a/frontend/src/pages/projects/settings/OrganizationSettingsPage.tsx b/frontend/src/pages/projects/settings/OrganizationSettingsPage.tsx index 0ad189d2..01c01560 100644 --- a/frontend/src/pages/projects/settings/OrganizationSettingsPage.tsx +++ b/frontend/src/pages/projects/settings/OrganizationSettingsPage.tsx @@ -4,8 +4,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useProject } from '@/hooks/useProjects' import { organizationsApi } from '@/api/organizations' import { projectsApi } from '@/api/projects' +import { useTranslation } from 'react-i18next' export function OrganizationSettingsPage() { + const { t } = useTranslation('project-settings') const { key } = useParams<{ key: string }>() const qc = useQueryClient() const { data: project, isLoading } = useProject(key!) @@ -32,39 +34,37 @@ export function OrganizationSettingsPage() { }, onError: (e: unknown) => { const status = (e as { response?: { status?: number } }).response?.status - setError(status === 403 - ? 'You must be an owner or admin of the target organization to assign this project to it.' - : 'Could not update the organization.') + setError(status === 403 ? t('org.forbidden') : t('org.updateFailed')) }, }) - if (isLoading || !project) return
Loading…
+ if (isLoading || !project) return
{t('common:loading')}
if (project.myRole !== 'ADMIN') { - return
You don't have permission to manage this project's organization.
+ return
{t('org.noPermission')}
} return (
-

Organization

+

{t('org.title')}

-
Current organization
+
{t('org.current')}
- {project.orgId ? (currentOrg?.name ?? '…') : 'Not assigned'} + {project.orgId ? (currentOrg?.name ?? '…') : t('org.notAssigned')}
{error &&

{error}

}
- +
{project.orgId && ( @@ -83,7 +83,7 @@ export function OrganizationSettingsPage() { disabled={setOrg.isPending} className="self-start text-xs text-red-400 hover:text-red-300 disabled:opacity-50" > - Remove from organization + {t('org.removeFromOrg')} )}
diff --git a/frontend/src/pages/projects/settings/ProjectAuditPage.tsx b/frontend/src/pages/projects/settings/ProjectAuditPage.tsx index 22362e94..2954af47 100644 --- a/frontend/src/pages/projects/settings/ProjectAuditPage.tsx +++ b/frontend/src/pages/projects/settings/ProjectAuditPage.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' import { useParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' import { auditApi, AuditEvent } from '../../../api/audit' import { DataTable, type Column } from '@/components/table/DataTable' +import { formatDateTime } from '@/i18n/format' const LEVEL_COLOR: Record = { SECURITY: 'bg-red-900/40 text-red-400', @@ -10,40 +12,41 @@ const LEVEL_COLOR: Record = { ALL: 'bg-gray-700 text-gray-300', } -const columns: Column[] = [ - { - key: 'time', - header: 'Time', - width: '180px', - cell: e => {new Date(e.timestamp).toLocaleString()}, - }, - { key: 'user', header: 'User', cell: e => e.userEmail }, - { - key: 'action', - header: 'Action', - cell: e => {e.action}, - }, - { - key: 'level', - header: 'Level', - width: '120px', - cell: e => ( - - {e.level} - - ), - }, - { - key: 'resource', - header: 'Resource', - cell: e => {e.resourceType} {e.resourceId}, - }, -] - export default function ProjectAuditPage() { + const { t } = useTranslation('project-settings') const { key } = useParams<{ key: string }>() const projectKey = key! + const columns: Column[] = [ + { + key: 'time', + header: t('audit.col.time'), + width: '180px', + cell: e => {formatDateTime(e.timestamp)}, + }, + { key: 'user', header: t('audit.col.user'), cell: e => e.userEmail }, + { + key: 'action', + header: t('audit.col.action'), + cell: e => {e.action}, + }, + { + key: 'level', + header: t('audit.col.level'), + width: '120px', + cell: e => ( + + {t(`audit.level.${e.level}`, { defaultValue: e.level })} + + ), + }, + { + key: 'resource', + header: t('audit.col.resource'), + cell: e => {e.resourceType} {e.resourceId}, + }, + ] + const [action, setAction] = useState('') const { data } = useQuery({ queryKey: ['project-audit', projectKey, action], @@ -55,11 +58,11 @@ export default function ProjectAuditPage() { return (
-

Audit Log

+

{t('audit.title')}

setAction(e.target.value)} /> @@ -68,7 +71,7 @@ export default function ProjectAuditPage() { columns={columns} rows={data?.content ?? []} rowKey={e => e.id} - empty="No audit events" + empty={t('audit.empty')} />
) diff --git a/frontend/src/pages/projects/settings/VersionsPage.tsx b/frontend/src/pages/projects/settings/VersionsPage.tsx index c252baa2..9b7e205f 100644 --- a/frontend/src/pages/projects/settings/VersionsPage.tsx +++ b/frontend/src/pages/projects/settings/VersionsPage.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom' import { useVersions, useCreateVersion, useUpdateVersion, useDeleteVersion } from '@/hooks/useVersions' import { useProjectRole } from '@/hooks/useProjectRole' import type { Version } from '@/types' +import { useTranslation } from 'react-i18next' function VersionForm({ initial, @@ -13,25 +14,26 @@ function VersionForm({ onSubmit: (name: string) => void onCancel: () => void }) { + const { t } = useTranslation('project-settings') const [name, setName] = useState(initial?.name ?? '') const [error, setError] = useState('') function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (!name.trim()) { setError('Name is required'); return } - if (name.trim().length > 50) { setError('Max 50 characters'); return } + if (!name.trim()) { setError(t('form.nameRequired')); return } + if (name.trim().length > 50) { setError(t('form.maxChars')); return } onSubmit(name.trim()) } return (
- + { setName(e.target.value); setError('') }} maxLength={50} - placeholder="e.g. v1.0.0" + placeholder={t('versions.namePlaceholder')} autoFocus className="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm text-white outline-none focus:border-blue-500" /> @@ -39,10 +41,10 @@ function VersionForm({
@@ -50,6 +52,7 @@ function VersionForm({ } export function VersionsPage() { + const { t } = useTranslation('project-settings') const { key } = useParams<{ key: string }>() const { canWrite } = useProjectRole(key!) const { data: versions = [], isLoading } = useVersions(key!) @@ -67,7 +70,7 @@ export function VersionsPage() { setShowCreate(false) setApiError('') } catch { - setApiError('A version with that name already exists.') + setApiError(t('versions.duplicate')) } } @@ -78,27 +81,27 @@ export function VersionsPage() { setEditing(null) setApiError('') } catch { - setApiError('A version with that name already exists.') + setApiError(t('versions.duplicate')) } } async function handleDelete(id: string) { - if (!confirm('Delete this version? It will be removed from all issues.')) return + if (!confirm(t('versions.deleteConfirm'))) return await deleteVersion.mutateAsync(id) } - if (isLoading) return
Loading…
+ if (isLoading) return
{t('common:loading')}
return (
-

Versions

+

{t('versions.title')}

{canWrite && !showCreate && ( )}
@@ -127,13 +130,13 @@ export function VersionsPage() { onClick={() => setEditing(version)} className="text-xs text-gray-400 hover:text-white px-2 py-1 rounded hover:bg-gray-700" > - Edit + {t('form.edit')}
)} @@ -142,7 +145,7 @@ export function VersionsPage() {
))} {versions.length === 0 && !showCreate && ( -

No versions yet. Create your first one!

+

{t('versions.empty')}

)}