Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/de/platform/admin/two-factor-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Behandle Backup-Codes wie Passwörter. Lege sie in einen Passwort-Manager oder d

Ein Passkey ist ein WebAuthn-Credential — Face ID, Touch ID, Windows Hello oder ein Hardware-Security-Key —, das bei jeder Anmeldung eine Challenge signiert, statt einen getippten Code zu liefern. Das Credential ist an die Origin der Site gebunden; eine täuschend ähnliche Phishing-Domain bekommt nichts, was sie wiederverwenden könnte. Ein Passkey ist dadurch phishing-resistent auf eine Art, die TOTP nicht erreicht, und er erfüllt eine erzwungene Zwei-Faktor-Richtlinie genau wie TOTP.

Zum Registrieren öffne **Konto > Sicherheit** und klick auf **Passkey hinzufügen**. Gib dem Credential einen Namen, den du später wiedererkennst, und wähle den **Authenticator-Typ**: **Beliebig (empfohlen)** lässt den Browser alles anbieten, was verfügbar ist, **Dieses Gerät (Face ID, Touch ID, Windows Hello)** beschränkt die Zeremonie auf den eingebauten Authenticator, und **Security-Key oder Smartphone** auf einen externen. Den Rest erledigt der Browser mit der Registrierungszeremonie. Dieselbe Liste trägt **Entfernen** zum Widerrufen deiner eigenen Credentials.
Zum Registrieren öffne **Konto > Sicherheit** und klick auf **Passkey hinzufügen**. Gib dem Credential einen Namen, den du später wiedererkennst, und wähle den **Authenticator-Typ**: **Beliebig (empfohlen)** lässt den Browser alles anbieten, was verfügbar ist, **Dieses Gerät (Face ID, Touch ID, Windows Hello)** beschränkt die Zeremonie auf den eingebauten Authenticator, und **Security-Key oder Smartphone** auf einen externen. Den Rest erledigt der Browser mit der Registrierungszeremonie. Jeder Eintrag in derselben Liste trägt eine **Entfernen**-Schaltfläche (Symbol) zum Widerrufen deiner eigenen Credentials; sie fragt vor dem Entfernen des Passkeys nach einer Bestätigung.

Ein registrierter Passkey funktioniert an drei Türen. Auf dem Login-Bildschirm meldet dich **Mit einem Passkey anmelden** ohne Passwort an — das Credential ist selbst ein starker Nachweis. Auf dem Bestätigungs-Bildschirm nach einem Passwort-Login ersetzt **Stattdessen einen Passkey verwenden** den sechsstelligen Code. Und auf dem Registrierungs-Bildschirm, zu dem eine erzwungene Richtlinie nicht registrierte Mitglieder leitet, sitzt **Stattdessen einen Passkey registrieren** neben der TOTP-Einrichtung — ein Mitglied, das nur einen Passkey registriert und nie TOTP, besteht die Richtlinie.

Expand Down
2 changes: 1 addition & 1 deletion docs/en/platform/admin/two-factor-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Treat backup codes like passwords. Store them in a password manager or print the

A passkey is a WebAuthn credential — Face ID, Touch ID, Windows Hello, or a hardware security key — that signs a per-login challenge instead of producing a typed code. The credential is bound to the site's origin, so a look-alike phishing domain gets nothing to replay; that makes a passkey phishing-resistant in a way TOTP is not, and it satisfies an enforced two-factor policy exactly like TOTP does.

To register one, open **Account > Security** and click **Add a passkey**. Give the credential a name you will recognise later, then pick the **Authenticator type**: **Any (recommended)** lets the browser offer everything available, **This device (Face ID, Touch ID, Windows Hello)** narrows the ceremony to the built-in authenticator, and **Security key or phone** narrows it to a roaming one. The browser runs the registration ceremony from there. The same list carries **Remove** for revoking your own credentials.
To register one, open **Account > Security** and click **Add a passkey**. Give the credential a name you will recognise later, then pick the **Authenticator type**: **Any (recommended)** lets the browser offer everything available, **This device (Face ID, Touch ID, Windows Hello)** narrows the ceremony to the built-in authenticator, and **Security key or phone** narrows it to a roaming one. The browser runs the registration ceremony from there. Each entry in the same list carries a **Remove** icon button for revoking your own credentials; it asks you to confirm before the passkey is removed.

A registered passkey works at three doors. On the login screen, **Sign in with a passkey** signs you in without typing the password — the credential is itself strong proof. On the verification screen after a password login, **Use a passkey instead** replaces the six-digit code. And on the enrolment screen an enforced policy routes unenrolled members to, **Register a passkey instead** sits next to the TOTP setup — a member who registers only a passkey, never TOTP, passes the policy.

Expand Down
2 changes: 1 addition & 1 deletion docs/fr/platform/admin/two-factor-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Traite les codes de secours comme des mots de passe. Range-les dans un gestionna

Un passkey est un identifiant WebAuthn — Face ID, Touch ID, Windows Hello ou une clé de sécurité matérielle — qui signe un défi à chaque connexion au lieu de produire un code à taper. L'identifiant est lié à l'origine du site : un domaine de phishing qui lui ressemble n'obtient rien à rejouer. Un passkey résiste donc au phishing d'une façon que TOTP n'atteint pas, et il satisfait une politique de deux facteurs appliquée exactement comme TOTP.

Pour en enregistrer un, ouvre **Compte > Sécurité** et clique sur **Ajouter un passkey**. Donne à l'identifiant un nom que tu reconnaîtras plus tard, puis choisis le **Type d'authentificateur** : **Indifférent (recommandé)** laisse le navigateur proposer tout ce qui est disponible, **Cet appareil (Face ID, Touch ID, Windows Hello)** restreint la cérémonie à l'authentificateur intégré, et **Clé de sécurité ou téléphone** à un authentificateur itinérant. Le navigateur déroule la cérémonie d'enregistrement à partir de là. La même liste porte **Supprimer** pour révoquer tes propres passkeys.
Pour en enregistrer un, ouvre **Compte > Sécurité** et clique sur **Ajouter un passkey**. Donne à l'identifiant un nom que tu reconnaîtras plus tard, puis choisis le **Type d'authentificateur** : **Indifférent (recommandé)** laisse le navigateur proposer tout ce qui est disponible, **Cet appareil (Face ID, Touch ID, Windows Hello)** restreint la cérémonie à l'authentificateur intégré, et **Clé de sécurité ou téléphone** à un authentificateur itinérant. Le navigateur déroule la cérémonie d'enregistrement à partir de là. Chaque entrée de la même liste porte un bouton-icône **Supprimer** pour révoquer tes propres passkeys ; il demande une confirmation avant de supprimer le passkey.

Un passkey enregistré fonctionne à trois portes. Sur l'écran de connexion, **Se connecter avec un passkey** te connecte sans taper le mot de passe — l'identifiant est lui-même une preuve forte. Sur l'écran de vérification après une connexion par mot de passe, **Utiliser un passkey à la place** remplace le code à six chiffres. Et sur l'écran d'inscription vers lequel une politique appliquée route les membres non inscrits, **Enregistrer un passkey à la place** se trouve à côté de la configuration TOTP — un membre qui n'enregistre qu'un passkey, jamais TOTP, passe la politique.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* per-step live panels), reusing it rather than rebuilding a live view here.
*/
import { Badge } from '@tale/ui/badge';
import { Button } from '@tale/ui/button';
import { IconButton } from '@tale/ui/icon-button';
import { SkeletonText } from '@tale/ui/skeleton';
import {
Table,
Expand All @@ -19,7 +19,7 @@ import {
} from '@tale/ui/table';
import { Text } from '@tale/ui/text';
import { useNavigate } from '@tanstack/react-router';
import { Activity } from 'lucide-react';
import { Activity, Eye } from 'lucide-react';
import { useMemo } from 'react';

import { useT } from '@/lib/i18n/client';
Expand Down Expand Up @@ -106,8 +106,11 @@ export function RunList({ title, workflowSlug }: RunListProps) {
</TableCell>
<TableCell>{fmt(run.startedAt)}</TableCell>
<TableCell>
<Button
variant="secondary"
<IconButton
variant="ghost"
size="sm"
icon={Eye}
aria-label={t('runs.watchLive')}
disabled={!id}
onClick={() =>
// A project-scoped app watches runs under its project
Expand Down Expand Up @@ -136,9 +139,7 @@ export function RunList({ title, workflowSlug }: RunListProps) {
search: { wf: workflowSlug },
}))
}
>
{t('runs.watchLive')}
</Button>
/>
</TableCell>
</TableRow>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
'use client';

import { Button } from '@tale/ui/button';
import { IconButton } from '@tale/ui/icon-button';
import { HStack, Stack, VStack } from '@tale/ui/layout';
import { Text } from '@tale/ui/text';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';

import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog';
import { SettingsSection } from '@/app/features/settings/components/settings-section';
import { useConvexQuery } from '@/app/hooks/use-convex-query';
import { useToast } from '@/app/hooks/use-toast';
Expand Down Expand Up @@ -43,6 +46,11 @@ export function PasskeySection() {
const { data: status } = useConvexQuery(api.two_factor.queries.getStatus, {});

const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
// Revoking a passkey is destructive (it can drop the user's only
// phishing-resistant second factor), so it confirms via `DeleteDialog`
// rather than firing on the row's click. Track the row pending revocation.
const [revokeTarget, setRevokeTarget] = useState<PasskeyRow | null>(null);
const [isRevoking, setIsRevoking] = useState(false);

const { data: passkeys, isLoading } = useQuery({
queryKey: PASSKEYS_QUERY_KEY,
Expand All @@ -62,9 +70,13 @@ export function PasskeySection() {
void queryClient.invalidateQueries({ queryKey: PASSKEYS_QUERY_KEY });
}

async function revoke(id: string) {
async function confirmRevoke() {
if (!revokeTarget) return;
setIsRevoking(true);
try {
const result = await authClient.passkey.deletePasskey({ id });
const result = await authClient.passkey.deletePasskey({
id: revokeTarget.id,
});
if (result?.error) {
toast({
title: t('passkeys.errors.revokeFailed'),
Expand All @@ -74,11 +86,14 @@ export function PasskeySection() {
}
toast({ title: t('passkeys.revoked'), variant: 'success' });
invalidate();
setRevokeTarget(null);
} catch {
toast({
title: t('passkeys.errors.revokeFailed'),
variant: 'destructive',
});
} finally {
setIsRevoking(false);
}
}

Expand Down Expand Up @@ -107,9 +122,12 @@ export function PasskeySection() {
{pk.name?.trim() || t('passkeys.unnamed')}
</Text>
</VStack>
<Button variant="ghost" onClick={() => revoke(pk.id)}>
{t('passkeys.revokeButton')}
</Button>
<IconButton
icon={Trash2}
variant="ghost"
aria-label={t('passkeys.revokeButton')}
onClick={() => setRevokeTarget(pk)}
/>
</HStack>
))}
</Stack>
Expand All @@ -129,6 +147,20 @@ export function PasskeySection() {
invalidate();
}}
/>

<DeleteDialog
open={revokeTarget !== null}
onOpenChange={(open) => {
if (!open) setRevokeTarget(null);
}}
title={t('passkeys.revokeConfirmTitle')}
description={t('passkeys.revokeConfirmDescription', {
name: revokeTarget?.name?.trim() || t('passkeys.unnamed'),
})}
deleteText={t('passkeys.revokeButton')}
isDeleting={isRevoking}
onDelete={() => void confirmRevoke()}
/>
</SettingsSection>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Alert } from '@tale/ui/alert';
import { Button } from '@tale/ui/button';
import { IconButton } from '@tale/ui/icon-button';
import { Row, Stack } from '@tale/ui/layout';
import { Skeletonize } from '@tale/ui/skeleton-context';
import {
Expand Down Expand Up @@ -662,7 +663,6 @@ function CategoryList({
onToggleEnabled,
}: CategoryListProps) {
const { t } = useT('governance');
const { t: tCommon } = useT('common');
return (
<Stack gap={2}>
{categories.length === 0 ? (
Expand Down Expand Up @@ -700,7 +700,7 @@ function CategoryList({
<TableCell>{category.patterns.length}</TableCell>
<TableCell>
<Row gap={1} align="stretch" justify="end">
<Button
<IconButton
variant="ghost"
size="sm"
icon={Pencil}
Expand All @@ -709,10 +709,8 @@ function CategoryList({
})}
disabled={disabled}
onClick={() => onEdit(index)}
>
{tCommon('actions.edit')}
</Button>
<Button
/>
<IconButton
variant="ghost"
size="sm"
icon={Trash2}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { Button } from '@tale/ui/button';
import { IconButton } from '@tale/ui/icon-button';
import { Stack } from '@tale/ui/layout';
import {
Table,
Expand Down Expand Up @@ -30,7 +31,6 @@ export function MappingList({
onEdit,
}: MappingListProps) {
const { t } = useT('governance');
const { t: tCommon } = useT('common');
return (
<Stack gap={2}>
{mappings.length === 0 ? (
Expand Down Expand Up @@ -79,14 +79,16 @@ export function MappingList({
: t('moderationProvider.no')}
</TableCell>
<TableCell className="text-right">
<Button
<IconButton
variant="ghost"
size="sm"
icon={Pencil}
aria-label={t('moderationProvider.editMappingAria', {
category: mapping.providerCategory,
})}
disabled={disabled}
onClick={() => onEdit(index)}
>
{tCommon('actions.edit')}
</Button>
/>
</TableCell>
</TableRow>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import { Badge } from '@tale/ui/badge';
import { Button } from '@tale/ui/button';
import { IconButton } from '@tale/ui/icon-button';
import { Row, Stack } from '@tale/ui/layout';
import { Text } from '@tale/ui/text';
import type { ColumnDef } from '@tanstack/react-table';
import { Lock } from 'lucide-react';
import { Lock, LockOpen } from 'lucide-react';
import { useMemo, useState } from 'react';

import { TableDateCell } from '@/app/components/ui/data-display/table-date-cell';
Expand Down Expand Up @@ -157,16 +158,17 @@ export function ActiveHoldsSection({
meta: { isAction: true, align: 'right' as const },
cell: ({ row }) => (
<Row gap={0} align="stretch" justify="end">
<Button
<IconButton
type="button"
variant="ghost"
size="sm"
icon={LockOpen}
aria-label={t('legalHold.actions.requestRelease')}
onClick={(e) => {
e.stopPropagation();
setReleaseHoldId(row.original._id);
}}
>
{t('legalHold.actions.requestRelease')}
</Button>
/>
</Row>
),
size: 140,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import { Badge } from '@tale/ui/badge';
import { Button } from '@tale/ui/button';
import { IconButton } from '@tale/ui/icon-button';
import { Row } from '@tale/ui/layout';
import { Text } from '@tale/ui/text';
import type { ColumnDef } from '@tanstack/react-table';
import { Archive, Pencil } from 'lucide-react';
import { useMemo, useState } from 'react';

import { TableDateCell } from '@/app/components/ui/data-display/table-date-cell';
Expand Down Expand Up @@ -92,30 +94,30 @@ export function MattersSection({ organizationId }: MattersSectionProps) {
header: t('legalHold.columns.actions'),
meta: { isAction: true, align: 'right' as const },
cell: ({ row }) => (
<Row gap={2} align="stretch" justify="end">
<Button
<Row gap={1} align="stretch" justify="end">
<IconButton
type="button"
variant="ghost"
size="sm"
icon={Pencil}
aria-label={t('legalHold.actions.editMatter')}
onClick={(e) => {
e.stopPropagation();
setEditing(row.original);
}}
>
{t('legalHold.actions.editMatter')}
</Button>
/>
{row.original.status === 'open' && (
<Button
<IconButton
type="button"
variant="ghost"
size="sm"
icon={Archive}
aria-label={t('legalHold.actions.closeMatter')}
onClick={(e) => {
e.stopPropagation();
setClosing(row.original);
}}
>
{t('legalHold.actions.closeMatter')}
</Button>
/>
)}
</Row>
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
'use client';

import { Badge } from '@tale/ui/badge';
import { Button } from '@tale/ui/button';
import { IconButton } from '@tale/ui/icon-button';
import { Row, Stack } from '@tale/ui/layout';
import { Text } from '@tale/ui/text';
import type { ColumnDef } from '@tanstack/react-table';
import { Check, X } from 'lucide-react';
import { useMemo, useState } from 'react';

import { TableDateCell } from '@/app/components/ui/data-display/table-date-cell';
Expand Down Expand Up @@ -99,34 +100,34 @@ export function ReleaseRequestsSection({
cell: ({ row }) => {
const isSelf = currentUser?.userId === row.original.requestedBy;
return (
<Row gap={2} align="stretch" justify="end">
<Button
<Row gap={1} align="stretch" justify="end">
<IconButton
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setApproveTarget(row.original);
}}
title={
icon={Check}
aria-label={t('legalHold.actions.approve')}
tooltip={
isSelf
? t('legalHold.dialogs.approveRelease.selfApproveBlocked')
: undefined
}
>
{t('legalHold.actions.approve')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
setApproveTarget(row.original);
}}
/>
<IconButton
type="button"
variant="ghost"
size="sm"
icon={X}
aria-label={t('legalHold.actions.reject')}
onClick={(e) => {
e.stopPropagation();
setRejectId(row.original._id);
}}
>
{t('legalHold.actions.reject')}
</Button>
/>
</Row>
);
},
Expand Down
Loading