From 9026aef7b177dc4324bcb7beb1b4bf4484a4b584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 18 Sep 2026 14:13:23 -0600 Subject: [PATCH 1/2] feat[backend](user-creation): added auto activated admin only local user creation (no smtp needed) --- backend/modules/iam/dto/user.go | 66 +++++++++++++++++++++++++++++ backend/modules/iam/handler/user.go | 17 +++++++- backend/modules/iam/usecase/user.go | 2 +- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/backend/modules/iam/dto/user.go b/backend/modules/iam/dto/user.go index b6796bef6..0348204bc 100644 --- a/backend/modules/iam/dto/user.go +++ b/backend/modules/iam/dto/user.go @@ -1,8 +1,11 @@ package dto import ( + "fmt" "time" + "unicode" + "unicode/utf8" "github.com/utmstack/utmstack/backend/modules/iam/domain" ) @@ -20,8 +23,71 @@ type CreateUserRequest struct { Name string `json:"name,omitempty"` LangKey string `json:"lang_key,omitempty"` RoleNames []string `json:"role_names,omitempty"` + Password *string `json:"password,omitempty"` } + +func (self *CreateUserRequest) CheckValidPassword() error { + + if self.Password==nil { + return fmt.Errorf("no password") + } + + password := *self.Password + + if !utf8.ValidString(password) { + return fmt.Errorf("password contains invalid UTF-8") + } + + passwordRunes := []rune(password) + + if len(passwordRunes) < 8 { + return fmt.Errorf("password must have at least 8 characters") + } + + var ( + hasUpper bool + hasLower bool + hasSpecial bool + ) + + for _, r := range passwordRunes { + switch { + case unicode.IsUpper(r): + hasUpper = true + + case unicode.IsLower(r): + hasLower = true + + case unicode.IsLetter(r) || unicode.IsDigit(r): + //continue + + case unicode.IsPunct(r) || unicode.IsSymbol(r): + hasSpecial = true + + default: + return fmt.Errorf("password contains invalid characters") + } + } + + if !hasUpper { + return fmt.Errorf("password must contain at least one uppercase letter") + } + + if !hasLower { + return fmt.Errorf("password must contain at least one lowercase letter") + } + + if !hasSpecial { + return fmt.Errorf("password must contain at least one special character") + } + + return nil +} + + + + type UpdateUserRequest struct { Email string `json:"email,omitempty" binding:"omitempty,email"` Name string `json:"name,omitempty"` diff --git a/backend/modules/iam/handler/user.go b/backend/modules/iam/handler/user.go index 1ac1b77bc..2b8769beb 100644 --- a/backend/modules/iam/handler/user.go +++ b/backend/modules/iam/handler/user.go @@ -83,7 +83,21 @@ func (h *UserHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - resp, err := h.userUsecase.Create(c.Request.Context(), input, iam_connectors.CreateUserOptions{Invite: true}) + + creationOption := iam_connectors.CreateUserOptions{Invite: true} + + if input.Password!=nil && *input.Password!="" { + + if err:=input.CheckValidPassword();err!=nil{ + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + creationOption.Invite=false + creationOption.Password=*input.Password + } + + resp, err := h.userUsecase.Create(c.Request.Context(), input, creationOption ) audit.Record(c, audit_connectors.Event{Action: "user.create"}, audit_domain.USER_CREATION_ATTEMPT, audit_domain.USER_CREATION_SUCCESS, err) if err != nil { writeUserError(c, err) @@ -92,6 +106,7 @@ func (h *UserHandler) Create(c *gin.Context) { c.JSON(http.StatusCreated, resp) } + // @Summary Update user // @Tags Users // @Security BearerAuth diff --git a/backend/modules/iam/usecase/user.go b/backend/modules/iam/usecase/user.go index e8e8c9f95..0b70e777b 100644 --- a/backend/modules/iam/usecase/user.go +++ b/backend/modules/iam/usecase/user.go @@ -2,7 +2,6 @@ package usecase import ( "context" - "github.com/google/uuid" "github.com/threatwinds/go-sdk/catcher" "github.com/utmstack/utmstack/backend/modules/iam/connectors" @@ -151,6 +150,7 @@ func (u *userUsecase) Create(ctx context.Context, input dto.CreateUserRequest, o return u.toDetail(ctx, user) } + func (u *userUsecase) Update(ctx context.Context, id uuid.UUID, input dto.UpdateUserRequest) (*dto.UserDetailResponse, error) { user, err := u.userRepo.FindByID(ctx, id) if err != nil { From 36e59c80f197f556f34c635f0875d9ca863e02b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 18 Sep 2026 14:14:06 -0600 Subject: [PATCH 2/2] feat[frontend](team): split TeamPage into components, add local user creation with password --- .../src/features/team/components/avatar.tsx | 68 + .../team/components/delete-role-dialog.tsx | 71 + .../team/components/invite-dialog.tsx | 300 ++++ .../team/components/member-drawer.tsx | 373 +++++ .../features/team/components/member-row.tsx | 74 + .../features/team/components/role-badge.tsx | 22 + .../features/team/components/role-editor.tsx | 263 +++ .../features/team/components/role-picker.tsx | 78 + .../features/team/components/roles-view.tsx | 268 ++++ frontend/src/features/team/lib/team-utils.ts | 97 ++ frontend/src/features/team/pages/TeamPage.tsx | 1425 ++--------------- .../src/features/team/types/team.types.ts | 1 + frontend/src/shared/i18n/locales/de.json | 18 +- frontend/src/shared/i18n/locales/en.json | 18 +- frontend/src/shared/i18n/locales/es.json | 18 +- frontend/src/shared/i18n/locales/fr.json | 18 +- frontend/src/shared/i18n/locales/it.json | 18 +- frontend/src/shared/i18n/locales/pt.json | 18 +- frontend/src/shared/i18n/locales/ru.json | 18 +- 19 files changed, 1866 insertions(+), 1300 deletions(-) create mode 100644 frontend/src/features/team/components/avatar.tsx create mode 100644 frontend/src/features/team/components/delete-role-dialog.tsx create mode 100644 frontend/src/features/team/components/invite-dialog.tsx create mode 100644 frontend/src/features/team/components/member-drawer.tsx create mode 100644 frontend/src/features/team/components/member-row.tsx create mode 100644 frontend/src/features/team/components/role-badge.tsx create mode 100644 frontend/src/features/team/components/role-editor.tsx create mode 100644 frontend/src/features/team/components/role-picker.tsx create mode 100644 frontend/src/features/team/components/roles-view.tsx create mode 100644 frontend/src/features/team/lib/team-utils.ts diff --git a/frontend/src/features/team/components/avatar.tsx b/frontend/src/features/team/components/avatar.tsx new file mode 100644 index 000000000..adc23a074 --- /dev/null +++ b/frontend/src/features/team/components/avatar.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@/shared/lib/utils"; +import { fullName, initials } from "../lib/team-utils"; +import type { UserBase, UserStatus } from "../types/team.types"; + +const STATUS_STYLE: Record = { + active: { + badge: + "bg-emerald-500/15 text-emerald-600 ring-emerald-500/30 dark:text-emerald-300", + dot: "bg-emerald-500", + }, + pending: { + badge: + "bg-amber-500/15 text-amber-600 ring-amber-500/30 dark:text-amber-300", + dot: "bg-amber-500", + }, + suspended: { + badge: "bg-red-500/15 text-red-600 ring-red-500/30 dark:text-red-300", + dot: "bg-red-500", + }, + inactive: { + badge: "bg-muted text-muted-foreground ring-border", + dot: "bg-zinc-400", + }, +}; + +export function StatusBadge({ status }: { status: UserStatus }) { + const { t } = useTranslation(); + const style = STATUS_STYLE[status] ?? STATUS_STYLE.inactive; + return ( + + + {t(`team.status.${status}`, { defaultValue: status })} + + ); +} + +export function Avatar({ + user: u, + size = 36, +}: { + user: UserBase; + size?: number; +}) { + if (u.image_url) { + return ( + {fullName(u)} + ); + } + return ( + + {initials(u)} + + ); +} diff --git a/frontend/src/features/team/components/delete-role-dialog.tsx b/frontend/src/features/team/components/delete-role-dialog.tsx new file mode 100644 index 000000000..8d777bea6 --- /dev/null +++ b/frontend/src/features/team/components/delete-role-dialog.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/shared/components/ui/button"; +import { roleError, roleLabel } from "../lib/team-utils"; +import { rolesHttpService } from "../services/team-http.service"; +import type { RoleDetail } from "../types/team.types"; + +export function DeleteRoleDialog({ + role, + onClose, + onDeleted, +}: { + role: RoleDetail; + onClose: () => void; + onDeleted: () => void; +}) { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + + const remove = async () => { + setBusy(true); + try { + await rolesHttpService.remove(role.id); + toast.success(t("team.toast.roleDeleted")); + onDeleted(); + } catch (err) { + toast.error(roleError(err, t)); + } finally { + setBusy(false); + } + }; + + return ( +
!busy && onClose()} + > +
e.stopPropagation()} + > +
+ +

+ {t("team.roles.deleteConfirmTitle")} +

+
+
+ {t("team.roles.deleteConfirmBody", { + name: roleLabel(t, role.name, role.display_name), + })} +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/team/components/invite-dialog.tsx b/frontend/src/features/team/components/invite-dialog.tsx new file mode 100644 index 000000000..eef7298ab --- /dev/null +++ b/frontend/src/features/team/components/invite-dialog.tsx @@ -0,0 +1,300 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, Check, KeyRound, UserPlus, X } from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { cn } from "@/shared/lib/utils"; +import { toast } from "sonner"; +import { userError } from "../lib/team-utils"; +import { usersHttpService } from "../services/team-http.service"; +import type { Role } from "../types/team.types"; +import { RolePicker } from "./role-picker"; + +type Mode = "invite" | "local"; + +function isPasswordValid(pw: string): boolean { + return ( + pw.length >= 8 && + /[A-Z]/.test(pw) && + /[a-z]/.test(pw) && + /[^A-Za-z0-9]/.test(pw) + ); +} + +export function InviteDialog({ + roles, + onClose, + onCreated, +}: { + roles: Role[]; + onClose: () => void; + onCreated: () => void; +}) { + const { t } = useTranslation(); + const [mode, setMode] = useState("invite"); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [roleNames, setRoleNames] = useState(["ROLE_VIEWER"]); + const [busy, setBusy] = useState(false); + + // Errors surface only after the user pauses typing (200ms), not mid-keystroke. + const [idle, setIdle] = useState(true); + useEffect(() => { + setIdle(false); + const handle = setTimeout(() => setIdle(true), 200); + return () => clearTimeout(handle); + }, [email, password]); + + const isLocal = mode === "local"; + const emailValid = /.+@.+\..+/.test(email.trim()); + const emailInvalid = idle && email.length > 0 && !emailValid; + const pwdInvalid = + idle && isLocal && password.length > 0 && !isPasswordValid(password); + const valid = emailValid && (!isLocal || isPasswordValid(password)); + + const toggleRole = (nm: string) => + setRoleNames((rs) => + rs.includes(nm) ? rs.filter((r) => r !== nm) : [...rs, nm], + ); + + const submit = async () => { + if (!valid || busy) return; + setBusy(true); + try { + await usersHttpService.create({ + email: email.trim(), + name: name.trim() || undefined, + // No lang_key: the user inherits the platform-default language until they + // pick their own in their profile. + role_names: roleNames, + // Local users get a password set directly and are activated immediately; + // invited users receive an email instead. + ...(isLocal ? { password } : {}), + }); + toast.success( + isLocal + ? t("team.toast.localUserCreated") + : t("team.toast.invitationSent"), + ); + onCreated(); + } catch (err) { + toast.error(userError(err, t)); + } finally { + setBusy(false); + } + }; + + const pwdChecks: { ok: boolean; label: string }[] = [ + { ok: password.length >= 8, label: t("team.invite.passwordRules.length") }, + { + ok: /[A-Z]/.test(password) && /[a-z]/.test(password), + label: t("team.invite.passwordRules.case"), + }, + { + ok: /[^A-Za-z0-9]/.test(password), + label: t("team.invite.passwordRules.special"), + }, + ]; + + return ( +
+
e.stopPropagation()} + > +
+
+

+ + {t("team.invite.title")} +

+

+ {isLocal + ? t("team.invite.localDescription") + : t("team.invite.description")} +

+
+ +
+ +
+
+ setMode("invite")} + icon={UserPlus} + label={t("team.invite.modeInvite")} + /> + setMode("local")} + icon={KeyRound} + label={t("team.invite.modeLocal")} + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="jsmith@company.com" + className={emailInvalid ? "border-red-500" : undefined} + /> + {emailInvalid && ( +

+ + {t("team.invite.emailInvalid")} +

+ )} +
+ + setName(e.target.value)} + placeholder="Jane Smith" + /> + +
+ + {isLocal && ( +
+ + setPassword(e.target.value)} + autoComplete="new-password" + placeholder={t("team.invite.passwordPlaceholder")} + className={pwdInvalid ? "border-red-500" : undefined} + /> +
+ {pwdChecks.map((c) => { + const showRed = idle && password.length > 0 && !c.ok; + return ( + + {c.ok ? ( + + ) : showRed ? ( + + ) : ( + + )} + {c.label} + + ); + })} +
+

+ {t("team.invite.localUserNote")} +

+
+ )} + +
+ + +
+
+ +
+ + +
+
+
+ ); +} + +function ModeBtn({ + active, + onClick, + icon: Icon, + label, +}: { + active: boolean; + onClick: () => void; + icon: typeof UserPlus; + label: string; +}) { + return ( + + ); +} + +function DrawerField({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} diff --git a/frontend/src/features/team/components/member-drawer.tsx b/frontend/src/features/team/components/member-drawer.tsx new file mode 100644 index 000000000..f49e4e890 --- /dev/null +++ b/frontend/src/features/team/components/member-drawer.tsx @@ -0,0 +1,373 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2, ShieldOff, UserCheck, UserX, X } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/shared/components/ui/button"; +import { + fullName, + langLabel, + relativeTime, + userError, +} from "../lib/team-utils"; +import { usersHttpService } from "../services/team-http.service"; +import type { Role, UserDetail } from "../types/team.types"; +import { Avatar, StatusBadge } from "./avatar"; +import { RolePicker } from "./role-picker"; + +export function MemberDrawer({ + userId, + roles, + onClose, + onChanged, +}: { + userId: string; + roles: Role[]; + onClose: () => void; + onChanged: () => void; +}) { + const { t } = useTranslation(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [roleNames, setRoleNames] = useState([]); + const [confirmResetTfa, setConfirmResetTfa] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + usersHttpService + .get(userId) + .then((u) => { + if (cancelled) return; + setUser(u); + setRoleNames((u.roles ?? []).map((r) => r.name)); + }) + .catch(() => { + if (!cancelled) { + toast.error(t("team.toast.userLoadFailed")); + onClose(); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [userId, onClose, t]); + + // Pending = invited but never accepted. It can't be reactivated from here — + // the user has to finish setting their password first. + const isPending = user?.status === "pending"; + + // Admins only manage roles + account status. Profile details (name, email, + // language) are edited by the user themselves on their own Profile page. + const baseRoles = (user?.roles ?? []).map((r) => r.name); + const dirty = + roleNames.slice().sort().join(",") !== baseRoles.slice().sort().join(","); + + const toggleRole = (nm: string) => + setRoleNames((rs) => + rs.includes(nm) ? rs.filter((r) => r !== nm) : [...rs, nm], + ); + + const save = async () => { + if (!user || !dirty) return; + setBusy(true); + try { + await usersHttpService.assignRoles(user.id, roleNames); + toast.success(t("team.toast.rolesUpdated")); + onChanged(); + onClose(); + } catch (err) { + toast.error(userError(err, t)); + } finally { + setBusy(false); + } + }; + + const resetTfa = async () => { + if (!user) return; + setBusy(true); + try { + await usersHttpService.resetTfa(user.id); + toast.success(t("team.toast.tfaReset")); + setConfirmResetTfa(false); + onChanged(); + onClose(); + } catch (err) { + toast.error(userError(err, t)); + } finally { + setBusy(false); + } + }; + + const setActivated = async (activate: boolean) => { + if (!user) return; + setBusy(true); + try { + if (activate) { + await usersHttpService.update(user.id, { status: "active" }); + toast.success(t("team.toast.userReactivated")); + } else { + await usersHttpService.deactivate(user.id); + toast.success(t("team.toast.userDeactivated")); + } + onChanged(); + onClose(); + } catch (err) { + toast.error(userError(err, t)); + } finally { + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()} + > + {loading || !user ? ( +
+ + {t("team.drawer.loading")} +
+ ) : ( + <> +
+
+ +
+

+ {fullName(user)} +

+
+ {user.email} + {user.created_at + ? ` · ${t("team.drawer.created", { time: relativeTime(user.created_at, t) })}` + : ""} +
+
+
+ +
+ +
+ +
+ + {user.name?.trim() || "—"} + + + {user.email} + + + {user.lang_key + ? langLabel(user.lang_key) + : t("team.langDefault")} + +
+
+ + +
    +
  • + {t("team.drawer.status")}:{" "} + +
  • +
  • + + {t("team.drawer.tfa")}:{" "} + {user.tfa_enabled ? ( + + {t("team.drawer.tfaEnabled")} + + ) : ( + t("team.drawer.tfaNotConfigured") + )} + + {user.tfa_enabled && ( + + )} +
  • + {isPending && ( +
  • + {t("team.drawer.pendingNote")} +
  • + )} + {user.federated &&
  • {t("team.drawer.federatedNote")}
  • } + {user.updated_at && ( +
  • + {t("team.drawer.lastModified", { + time: relativeTime(user.updated_at, t), + })} +
  • + )} +
+
+ + + + {roleNames.length === 0 && ( +

+ {t("team.drawer.noRoleWarning")} +

+ )} +
+
+ +
+ {user.status === "active" ? ( + + ) : isPending ? ( + + {t("team.drawer.waitingInvite")} + + ) : ( + + )} +
+ + +
+
+ + {confirmResetTfa && ( +
!busy && setConfirmResetTfa(false)} + > +
e.stopPropagation()} + > +
+ +

+ {t("team.drawer.resetTfaConfirmTitle")} +

+
+
+ {t("team.drawer.resetTfaConfirmBody", { + name: fullName(user), + })} +
+
+ + +
+
+
+ )} + + )} +
+
+ ); +} + +export function DrawerSection({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
{children}
+
+ ); +} + +export function InfoRow({ + k, + children, +}: { + k: string; + children: React.ReactNode; +}) { + return ( + <> +
{k}
+
{children}
+ + ); +} diff --git a/frontend/src/features/team/components/member-row.tsx b/frontend/src/features/team/components/member-row.tsx new file mode 100644 index 000000000..dbf64fb22 --- /dev/null +++ b/frontend/src/features/team/components/member-row.tsx @@ -0,0 +1,74 @@ +import { useTranslation } from "react-i18next"; +import { KeyRound, Pencil, ShieldCheck, ShieldOff } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; +import { fullName } from "../lib/team-utils"; +import type { UserListItem } from "../types/team.types"; +import { Avatar } from "./avatar"; +import { RoleBadge } from "./role-badge"; +import { StatusBadge } from "./avatar"; + +export function MemberRow({ + user: u, + tableCols, + onOpen, +}: { + user: UserListItem; + tableCols: string; + onOpen: () => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ +
+
{fullName(u)}
+
+ {u.name?.trim() && {u.email}} + {u.federated && ( + + + {t("team.members.federated")} + + )} +
+
+
+
+ {(u.roles ?? []).length === 0 ? ( + + {t("team.members.noRole")} + + ) : ( + u.roles!.map((r) => ( + + )) + )} +
+
+ {u.tfa_enabled ? ( + + {t("team.members.on")} + + ) : ( + + {t("team.members.off")} + + )} +
+
+ +
+
+ +
+
+ ); +} diff --git a/frontend/src/features/team/components/role-badge.tsx b/frontend/src/features/team/components/role-badge.tsx new file mode 100644 index 000000000..b9e12230d --- /dev/null +++ b/frontend/src/features/team/components/role-badge.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next"; +import { Crown, Shield } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; +import { roleLabel } from "../lib/team-utils"; + +export function RoleBadge({ name, label }: { name: string; label: string }) { + const { t } = useTranslation(); + const isAdmin = name === "ROLE_ADMIN"; + return ( + + {isAdmin ? : } + {roleLabel(t, name, label)} + + ); +} diff --git a/frontend/src/features/team/components/role-editor.tsx b/frontend/src/features/team/components/role-editor.tsx new file mode 100644 index 000000000..a1ffbf596 --- /dev/null +++ b/frontend/src/features/team/components/role-editor.tsx @@ -0,0 +1,263 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Loader2, Shield, X } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { cn } from "@/shared/lib/utils"; +import { permResource, resourceLabel, roleError } from "../lib/team-utils"; +import { rolesHttpService } from "../services/team-http.service"; +import type { Permission, RoleDetail } from "../types/team.types"; + +export function RoleEditor({ + role, + onClose, + onSaved, +}: { + role: RoleDetail | null; + onClose: () => void; + onSaved: () => void; +}) { + const { t } = useTranslation(); + const [catalog, setCatalog] = useState(null); + const [name, setName] = useState(role?.name ?? "ROLE_"); + const [displayName, setDisplayName] = useState(role?.display_name ?? ""); + const [description, setDescription] = useState(role?.description ?? ""); + const [selected, setSelected] = useState( + (role?.permissions ?? []).map((p) => p.name), + ); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + rolesHttpService + .listPermissions() + .then((p) => { + if (!cancelled) setCatalog(p); + }) + .catch(() => { + if (!cancelled) toast.error(t("team.toast.permissionsLoadFailed")); + }); + return () => { + cancelled = true; + }; + }, [t]); + + const byResource = useMemo(() => { + const map = new Map(); + catalog?.forEach((p) => { + const resource = permResource(p.name); + if (!map.has(resource)) map.set(resource, []); + map.get(resource)!.push(p); + }); + return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])); + }, [catalog]); + + const valid = name.trim().length >= 2 && name.trim().length <= 50; + + const toggle = (perm: string) => + setSelected((s) => + s.includes(perm) ? s.filter((p) => p !== perm) : [...s, perm], + ); + + const toggleResource = (perms: Permission[]) => { + const names = perms.map((p) => p.name); + const allOn = names.every((n) => selected.includes(n)); + setSelected((s) => + allOn + ? s.filter((p) => !names.includes(p)) + : [...new Set([...s, ...names])], + ); + }; + + const submit = async () => { + if (!valid || busy) return; + setBusy(true); + try { + const body = { + name: name.trim(), + display_name: displayName.trim() || undefined, + description: description.trim() || undefined, + permissions: selected, + }; + if (role) { + await rolesHttpService.update(role.id, body); + toast.success(t("team.toast.roleUpdated")); + } else { + await rolesHttpService.create(body); + toast.success(t("team.toast.roleCreated")); + } + onSaved(); + } catch (err) { + toast.error(roleError(err, t)); + } finally { + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+
+

+ + {role ? t("team.roles.editTitle") : t("team.roles.createTitle")} +

+

+ {t("team.roles.editorSubtitle")} +

+
+ +
+ +
+
+ + setName(e.target.value)} + className="font-mono" + placeholder="ROLE_AUDITOR" + /> + + + setDisplayName(e.target.value)} + placeholder="Auditor" + /> + +
+ + setDescription(e.target.value)} + /> + + +
+
+ + + {t("team.roles.selectedCount", { count: selected.length })} + +
+ {catalog === null ? ( +
+ + {t("team.roles.loadingPermissions")} +
+ ) : ( +
+ {byResource.map(([resource, perms]) => ( +
+ +
+ {perms.map((p) => { + const on = selected.includes(p.name); + return ( + + ); + })} +
+
+ ))} +
+ )} +
+
+ +
+ + +
+
+
+ ); +} + +function DrawerField({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} diff --git a/frontend/src/features/team/components/role-picker.tsx b/frontend/src/features/team/components/role-picker.tsx new file mode 100644 index 000000000..1634c31bb --- /dev/null +++ b/frontend/src/features/team/components/role-picker.tsx @@ -0,0 +1,78 @@ +import { cn } from "@/shared/lib/utils"; +import { roleDesc, roleLabel } from "../lib/team-utils"; +import type { Role } from "../types/team.types"; +import { useTranslation } from "react-i18next"; +import { Check, Crown, Shield } from "lucide-react"; + +export function RolePicker({ + roles, + selected, + onToggle, + compact, +}: { + roles: Role[]; + selected: string[]; + onToggle: (name: string) => void; + compact?: boolean; +}) { + const { t } = useTranslation(); + if (roles.length === 0) { + return ( +

+ {t("team.rolePicker.none")} +

+ ); + } + return ( +
+ {roles.map((r) => { + const checked = selected.includes(r.name); + const isAdmin = r.name === "ROLE_ADMIN"; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/features/team/components/roles-view.tsx b/frontend/src/features/team/components/roles-view.tsx new file mode 100644 index 000000000..48c7bbcc9 --- /dev/null +++ b/frontend/src/features/team/components/roles-view.tsx @@ -0,0 +1,268 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Check, + Crown, + KeyRound, + Loader2, + Lock, + Pencil, + Plus, + Shield, + Trash2, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { + actionLabel, + permResource, + resourceLabel, + roleDesc, + roleLabel, +} from "../lib/team-utils"; +import { rolesHttpService } from "../services/team-http.service"; +import type { Permission, Role, RoleDetail } from "../types/team.types"; +import { RoleEditor } from "./role-editor"; +import { DeleteRoleDialog } from "./delete-role-dialog"; + +export function RolesView({ + roles, + onChanged, +}: { + roles: Role[]; + onChanged: () => void; +}) { + const { t } = useTranslation(); + const [details, setDetails] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + useEffect(() => { + if (roles.length === 0) return; + let cancelled = false; + setLoading(true); + setError(false); + Promise.all(roles.map((r) => rolesHttpService.get(r.id))) + .then((d) => { + if (!cancelled) setDetails(d); + }) + .catch(() => { + if (!cancelled) setError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [roles]); + + // Union of all permissions across roles, grouped by resource (rows of the matrix). + const byResource = useMemo(() => { + const map = new Map>(); + details?.forEach((d) => + d.permissions.forEach((p) => { + const resource = permResource(p.name); + if (!map.has(resource)) map.set(resource, new Map()); + map.get(resource)!.set(p.name, p); + }), + ); + return [...map.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([resource, perms]) => ({ + resource, + perms: [...perms.values()].sort((a, b) => a.name.localeCompare(b.name)), + })); + }, [details]); + + const has = (roleName: string, permName: string) => + details + ?.find((d) => d.name === roleName) + ?.permissions.some((p) => p.name === permName) ?? false; + + if (loading) { + return ( +
+ + {t("team.roles.loading")} +
+ ); + } + if (error || !details) { + return ( +
+ {t("team.roles.loadFailed")} +
+ ); + } + + return ( +
+
+ + {t("team.roles.systemNote")} + + +
+ +
+ {details.map((d) => ( +
+
+ {d.name === "ROLE_ADMIN" ? ( + + ) : ( + + )} +
+
+ {roleLabel(t, d.name, d.display_name)} + {d.system && ( + + + {t("team.roles.systemBadge")} + + )} +
+ + {d.name} + +
+ {!d.system && ( +
+ + +
+ )} +
+ {roleDesc(t, d.name, d.description) && ( +

+ {roleDesc(t, d.name, d.description)} +

+ )} +
+ + + {t("team.roles.permissionsCount", { + count: d.permissions.length, + })} + + · + + {t("team.roles.modulesCount", { + count: new Set(d.permissions.map((p) => permResource(p.name))) + .size, + })} + +
+
+ ))} +
+ +
+
+

+ {t("team.roles.matrixTitle")} +

+

+ {t("team.roles.matrixSubtitle")} +

+
+
+
{t("team.roles.permission")}
+ {details.map((d) => ( +
+ {roleLabel(t, d.name, d.display_name)} +
+ ))} +
+ {byResource.map(({ resource, perms }) => ( +
+
+ {resourceLabel(t, resource)} +
+ {perms.map((p) => ( +
+
+ {p.name} +
+ {p.description || + actionLabel(t, p.name.split(".")[1] ?? "")} +
+
+ {details.map((d) => ( + + ))} +
+ ))} +
+ ))} +
+ + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null); + onChanged(); + }} + /> + )} + {deleting && ( + setDeleting(null)} + onDeleted={() => { + setDeleting(null); + onChanged(); + }} + /> + )} +
+ ); +} + +export function matrixCols(n: number): string { + return `1fr ${Array.from({ length: n }, () => "110px").join(" ")}`; +} + +export function PermTick({ on }: { on: boolean }) { + return ( +
+ {on ? ( + + ) : ( + — + )} +
+ ); +} diff --git a/frontend/src/features/team/lib/team-utils.ts b/frontend/src/features/team/lib/team-utils.ts new file mode 100644 index 000000000..62aaadbfa --- /dev/null +++ b/frontend/src/features/team/lib/team-utils.ts @@ -0,0 +1,97 @@ +import type { TFunction } from "i18next"; +import { SUPPORTED_LANGUAGES } from "@/shared/i18n"; +import { TeamHttpError } from "../services/team-http.service"; +import type { UserBase } from "../types/team.types"; + +export const PAGE_SIZE = 20; +export const MEMBER_COLS = ["1.7fr", "1fr", 90, 130, 40]; + +/* Roles & permissions arrive from the backend as English DB strings. Translate the + * stable identifiers (role name, permission resource/action) with a fallback to the + * backend value so any custom/unknown role still renders something sensible. */ +export function roleLabel( + t: TFunction, + name: string, + fallback?: string, +): string { + return t(`team.roleNames.${name}`, { defaultValue: fallback || name }); +} +export function roleDesc( + t: TFunction, + name: string, + fallback?: string, +): string { + return t(`team.roleDescriptions.${name}`, { defaultValue: fallback || "" }); +} +export function resourceLabel(t: TFunction, resource: string): string { + return t(`team.resources.${resource}`, { defaultValue: resource }); +} +export function actionLabel(t: TFunction, action: string): string { + return t(`team.actions.${action}`, { defaultValue: action }); +} + +/* Permissions are flat "resource.action" strings; the two halves are what the + * matrix groups and labels by. */ +export function permResource(name: string): string { + return name.split(".")[0] ?? name; +} +export function permAction(name: string): string { + return name.split(".")[1] ?? ""; +} + +export function langLabel(code: string): string { + return SUPPORTED_LANGUAGES.find((l) => l.code === code)?.label ?? code; +} + +// An account can exist before anyone has typed a display name into it — every +// federated one starts that way — so the address is what is always there. +export function fullName(u: UserBase): string { + return u.name?.trim() || u.email; +} + +export function initials(u: UserBase): string { + return ( + fullName(u) + .split(/[\s.@_-]+/) + .map((p) => p[0]) + .filter(Boolean) + .slice(0, 2) + .join("") + .toUpperCase() || "U" + ); +} + +export function relativeTime(iso: string, t: TFunction): string { + if (!iso) return "—"; + const diff = Date.now() - new Date(iso).getTime(); + const m = Math.round(diff / 60_000); + if (m < 1) return t("team.relative.justNow"); + if (m < 60) return t("team.relative.minutesAgo", { count: m }); + const h = Math.round(m / 60); + if (h < 24) return t("team.relative.hoursAgo", { count: h }); + const d = Math.round(h / 24); + if (d < 30) return t("team.relative.daysAgo", { count: d }); + return new Date(iso).toLocaleDateString(); +} + +export function roleError(err: unknown, t: TFunction): string { + if (err instanceof TeamHttpError) { + if (err.status === 409) return t("team.toast.roleNameInUse"); + // The backend refuses to touch a seeded role whatever the request says. + if (err.status === 403) return t("team.toast.roleImmutable"); + if (err.status === 400) + return err.message || t("team.toast.invalidRequest"); + } + return err instanceof Error ? err.message : t("team.toast.operationFailed"); +} + +export function userError(err: unknown, t: TFunction): string { + if (err instanceof TeamHttpError) { + if (err.status === 409) return t("team.toast.emailInUse"); + if (err.status === 400) + return err.message || t("team.toast.invalidRequest"); + if (err.status === 403) return t("team.toast.noPermission"); + if (err.status === 404) return t("team.toast.userNotFound"); + } + return err instanceof Error ? err.message : t("team.toast.operationFailed"); +} diff --git a/frontend/src/features/team/pages/TeamPage.tsx b/frontend/src/features/team/pages/TeamPage.tsx index 81726620a..a8a3206b3 100644 --- a/frontend/src/features/team/pages/TeamPage.tsx +++ b/frontend/src/features/team/pages/TeamPage.tsx @@ -1,123 +1,87 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { AlertTriangle, - Check, - Crown, - KeyRound, Loader2, - Lock, - Pencil, - Plus, Search, Shield, - ShieldCheck, - ShieldOff, - Trash2, UserCheck, UserPlus, - UserX, - X, - type LucideIcon, -} from 'lucide-react' -import { toast } from 'sonner' -import { cn } from '@/shared/lib/utils' -import { Button } from '@/shared/components/ui/button' -import { Input } from '@/shared/components/ui/input' -import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' -import { ColumnResizeHandle } from '@/shared/components/ui/column-resize-handle' -import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' -import { SUPPORTED_LANGUAGES } from '@/shared/i18n' -import { rolesHttpService, TeamHttpError, usersHttpService } from '../services/team-http.service' -import type { - PageInfo, - Permission, - Role, - RoleDetail, - UserBase, - UserDetail, - UserListItem, - UserStatus, -} from '../types/team.types' - -const PAGE_SIZE = 20 -const MEMBER_COLS = ['1.7fr', '1fr', 90, 130, 40] - -/* Roles & permissions arrive from the backend as English DB strings. Translate the - * stable identifiers (role name, permission resource/action) with a fallback to the - * backend value so any custom/unknown role still renders something sensible. */ -function roleLabel(t: TFunction, name: string, fallback?: string): string { - return t(`team.roleNames.${name}`, { defaultValue: fallback || name }) -} -function roleDesc(t: TFunction, name: string, fallback?: string): string { - return t(`team.roleDescriptions.${name}`, { defaultValue: fallback || '' }) -} -function resourceLabel(t: TFunction, resource: string): string { - return t(`team.resources.${resource}`, { defaultValue: resource }) -} -function actionLabel(t: TFunction, action: string): string { - return t(`team.actions.${action}`, { defaultValue: action }) -} - -/* Permissions are flat "resource.action" strings; the two halves are what the - * matrix groups and labels by. */ -function permResource(name: string): string { - return name.split('.')[0] ?? name -} -function permAction(name: string): string { - return name.split('.')[1] ?? '' -} - -/* ───────────────────────────────────────────────────────────────────────── - * Page - * ───────────────────────────────────────────────────────────────────────── */ +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/shared/lib/utils"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { InfiniteScrollSentinel } from "@/shared/components/ui/infinite-scroll"; +import { ColumnResizeHandle } from "@/shared/components/ui/column-resize-handle"; +import { + colMins, + useResizableColumns, +} from "@/shared/hooks/useResizableColumns"; +import { MEMBER_COLS, PAGE_SIZE } from "../lib/team-utils"; +import { + rolesHttpService, + usersHttpService, +} from "../services/team-http.service"; +import type { PageInfo, Role, UserListItem } from "../types/team.types"; +import { MemberDrawer } from "../components/member-drawer"; +import { InviteDialog } from "../components/invite-dialog"; +import { MemberRow } from "../components/member-row"; +import { RolesView } from "../components/roles-view"; -type View = 'members' | 'roles' +type View = "members" | "roles"; export function TeamPage() { - const { t } = useTranslation() - const [view, setView] = useState('members') - const [roles, setRoles] = useState([]) + const { t } = useTranslation(); + const [view, setView] = useState("members"); + const [roles, setRoles] = useState([]); // Roles list is small and shared by the pickers + the roles tab. const loadRoles = useCallback(async () => { try { - setRoles(await rolesHttpService.list()) + setRoles(await rolesHttpService.list()); } catch { - toast.error(t('team.toast.rolesLoadFailed')) + toast.error(t("team.toast.rolesLoadFailed")); } - }, [t]) + }, [t]); useEffect(() => { - void loadRoles() - }, [loadRoles]) + void loadRoles(); + }, [loadRoles]); return (
- {t('team.title')} + {t("team.title")}
- setView('members')} icon={UserCheck}> - {t('team.tabs.members')} + setView("members")} + icon={UserCheck} + > + {t("team.tabs.members")} - setView('roles')} icon={Shield}> - {t('team.tabs.roles')} + setView("roles")} + icon={Shield} + > + {t("team.tabs.roles")}
- {view === 'members' ? ( + {view === "members" ? ( ) : ( void loadRoles()} /> )}
- ) + ); } function TabBtn({ @@ -126,99 +90,108 @@ function TabBtn({ icon: Icon, children, }: { - active: boolean - onClick: () => void - icon: LucideIcon - children: React.ReactNode + active: boolean; + onClick: () => void; + icon: typeof UserCheck; + children: React.ReactNode; }) { return ( - ) + ); } -/* ───────────────────────────────────────────────────────────────────────── - * Members view - * ───────────────────────────────────────────────────────────────────────── */ - function MembersView({ roles }: { roles: Role[] }) { - const { t } = useTranslation() - const [users, setUsers] = useState(null) - const [pageInfo, setPageInfo] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - - const [page, setPage] = useState(1) - const [search, setSearch] = useState('') - const [debounced, setDebounced] = useState('') - - const [openId, setOpenId] = useState(null) - const [inviteOpen, setInviteOpen] = useState(false) + const { t } = useTranslation(); + const [users, setUsers] = useState(null); + const [pageInfo, setPageInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [debounced, setDebounced] = useState(""); + + const [openId, setOpenId] = useState(null); + const [inviteOpen, setInviteOpen] = useState(false); const memberHeaders = [ - t('team.members.colUser'), - t('team.members.colRoles'), - t('team.members.col2fa'), - t('team.members.colStatus'), - '', - ] - const memberMins = [...colMins(memberHeaders.slice(0, -1)), 40] + t("team.members.colUser"), + t("team.members.colRoles"), + t("team.members.col2fa"), + t("team.members.colStatus"), + "", + ]; + const memberMins = [...colMins(memberHeaders.slice(0, -1)), 40]; const { template: memberCols, startDrag } = useResizableColumns(MEMBER_COLS, { min: memberMins, - storageKey: 'team-members-table-columns', - }) + storageKey: "team-members-table-columns", + }); // Debounce the search box, and reset to page 1 when the query changes. useEffect(() => { const handle = setTimeout(() => { - setDebounced(search.trim()) - setPage(1) - }, 300) - return () => clearTimeout(handle) - }, [search]) + setDebounced(search.trim()); + setPage(1); + }, 300); + return () => clearTimeout(handle); + }, [search]); const load = useCallback(async () => { - setLoading(true) - setError(false) + setLoading(true); + setError(false); try { - const resp = await usersHttpService.list({ page, page_size: PAGE_SIZE, search: debounced }) - setUsers((prev) => (page === 1 ? resp.data : [...(prev ?? []), ...resp.data])) - setPageInfo(resp.page_info) + const resp = await usersHttpService.list({ + page, + page_size: PAGE_SIZE, + search: debounced, + }); + setUsers((prev) => + page === 1 ? resp.data : [...(prev ?? []), ...resp.data], + ); + setPageInfo(resp.page_info); } catch { - setError(true) - setUsers([]) + setError(true); + setUsers([]); } finally { - setLoading(false) + setLoading(false); } - }, [page, debounced]) + }, [page, debounced]); useEffect(() => { - void load() - }, [load]) + void load(); + }, [load]); return ( <>
- + setSearch(e.target.value)} - placeholder={t('team.members.searchPlaceholder')} + placeholder={t("team.members.searchPlaceholder")} className="h-9 pl-9" />
@@ -228,9 +201,15 @@ function MembersView({ roles }: { roles: Role[] }) { style={{ gridTemplateColumns: memberCols }} > {memberHeaders.map((header, index, headers) => ( -
+
{header} - {index < headers.length - 1 && } + {index < headers.length - 1 && ( + + )}
))}
@@ -238,7 +217,7 @@ function MembersView({ roles }: { roles: Role[] }) { {loading && (!users || users.length === 0) && (
- {t('team.members.loading')} + {t("team.members.loading")}
)} @@ -246,22 +225,32 @@ function MembersView({ roles }: { roles: Role[] }) {
- {t('team.members.loadFailed')} + {t("team.members.loadFailed")}
)} {!loading && !error && users && users.length === 0 && (
- {debounced ? t('team.members.noSearchMatch') : t('team.members.none')} + {debounced + ? t("team.members.noSearchMatch") + : t("team.members.none")}
)} - {users && users.length > 0 && - users.map((u) => setOpenId(u.id)} />)} + {users && + users.length > 0 && + users.map((u) => ( + setOpenId(u.id)} + /> + ))} {users && users.length > 0 && ( @@ -269,7 +258,9 @@ function MembersView({ roles }: { roles: Role[] }) { onReach={() => setPage((p) => p + 1)} hasMore={users.length < (pageInfo?.total_items ?? 0)} loading={loading} - endLabel={t('common.allLoaded', { count: pageInfo?.total_items ?? 0 })} + endLabel={t("common.allLoaded", { + count: pageInfo?.total_items ?? 0, + })} /> )} @@ -286,1150 +277,12 @@ function MembersView({ roles }: { roles: Role[] }) { roles={roles} onClose={() => setInviteOpen(false)} onCreated={() => { - setInviteOpen(false) - setPage(1) - void load() + setInviteOpen(false); + setPage(1); + void load(); }} /> )} - ) -} - -function MemberRow({ user: u, tableCols, onOpen }: { user: UserListItem; tableCols: string; onOpen: () => void }) { - const { t } = useTranslation() - return ( -
-
- -
-
{fullName(u)}
-
- {u.name?.trim() && {u.email}} - {u.federated && ( - - - {t('team.members.federated')} - - )} -
-
-
-
- {(u.roles ?? []).length === 0 ? ( - {t('team.members.noRole')} - ) : ( - u.roles!.map((r) => ) - )} -
-
- {u.tfa_enabled ? ( - - {t('team.members.on')} - - ) : ( - - {t('team.members.off')} - - )} -
-
- -
-
- -
-
- ) -} - -const STATUS_STYLE: Record = { - active: { - badge: - 'bg-emerald-500/15 text-emerald-600 ring-emerald-500/30 dark:text-emerald-300', - dot: 'bg-emerald-500', - }, - pending: { - badge: 'bg-amber-500/15 text-amber-600 ring-amber-500/30 dark:text-amber-300', - dot: 'bg-amber-500', - }, - suspended: { - badge: 'bg-red-500/15 text-red-600 ring-red-500/30 dark:text-red-300', - dot: 'bg-red-500', - }, - inactive: { badge: 'bg-muted text-muted-foreground ring-border', dot: 'bg-zinc-400' }, -} - -function StatusBadge({ status }: { status: UserStatus }) { - const { t } = useTranslation() - const style = STATUS_STYLE[status] ?? STATUS_STYLE.inactive - return ( - - - {t(`team.status.${status}`, { defaultValue: status })} - - ) -} - -function RoleBadge({ name, label }: { name: string; label: string }) { - const { t } = useTranslation() - const isAdmin = name === 'ROLE_ADMIN' - return ( - - {isAdmin ? : } - {roleLabel(t, name, label)} - - ) -} - -function Avatar({ user: u, size = 36 }: { user: UserBase; size?: number }) { - if (u.image_url) { - return ( - {fullName(u)} - ) - } - return ( - - {initials(u)} - - ) -} - -/* ───────────────────────────────────────────────────────────────────────── - * Member drawer — load detail, edit roles, activate/deactivate - * ───────────────────────────────────────────────────────────────────────── */ - -function MemberDrawer({ - userId, - roles, - onClose, - onChanged, -}: { - userId: string - roles: Role[] - onClose: () => void - onChanged: () => void -}) { - const { t } = useTranslation() - const [user, setUser] = useState(null) - const [loading, setLoading] = useState(true) - const [busy, setBusy] = useState(false) - const [roleNames, setRoleNames] = useState([]) - const [confirmResetTfa, setConfirmResetTfa] = useState(false) - - useEffect(() => { - let cancelled = false - setLoading(true) - usersHttpService - .get(userId) - .then((u) => { - if (cancelled) return - setUser(u) - setRoleNames((u.roles ?? []).map((r) => r.name)) - }) - .catch(() => { - if (!cancelled) { - toast.error(t('team.toast.userLoadFailed')) - onClose() - } - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - return () => { - cancelled = true - } - }, [userId, onClose, t]) - - // Pending = invited but never accepted. It can't be reactivated from here — - // the user has to finish setting their password first. - const isPending = user?.status === 'pending' - - // Admins only manage roles + account status. Profile details (name, email, - // language) are edited by the user themselves on their own Profile page. - const baseRoles = (user?.roles ?? []).map((r) => r.name) - const dirty = roleNames.slice().sort().join(',') !== baseRoles.slice().sort().join(',') - - const toggleRole = (name: string) => - setRoleNames((rs) => (rs.includes(name) ? rs.filter((r) => r !== name) : [...rs, name])) - - const save = async () => { - if (!user || !dirty) return - setBusy(true) - try { - await usersHttpService.assignRoles(user.id, roleNames) - toast.success(t('team.toast.rolesUpdated')) - onChanged() - onClose() - } catch (err) { - toast.error(userError(err, t)) - } finally { - setBusy(false) - } - } - - const resetTfa = async () => { - if (!user) return - setBusy(true) - try { - await usersHttpService.resetTfa(user.id) - toast.success(t('team.toast.tfaReset')) - setConfirmResetTfa(false) - onChanged() - onClose() - } catch (err) { - toast.error(userError(err, t)) - } finally { - setBusy(false) - } - } - - const setActivated = async (activate: boolean) => { - if (!user) return - setBusy(true) - try { - if (activate) { - await usersHttpService.update(user.id, { status: 'active' }) - toast.success(t('team.toast.userReactivated')) - } else { - await usersHttpService.deactivate(user.id) - toast.success(t('team.toast.userDeactivated')) - } - onChanged() - onClose() - } catch (err) { - toast.error(userError(err, t)) - } finally { - setBusy(false) - } - } - - return ( -
-
e.stopPropagation()} - > - {loading || !user ? ( -
- - {t('team.drawer.loading')} -
- ) : ( - <> -
-
- -
-

{fullName(user)}

-
- {user.email} - {user.created_at ? ` · ${t('team.drawer.created', { time: relativeTime(user.created_at, t) })}` : ''} -
-
-
- -
- -
- -
- {user.name?.trim() || '—'} - - {user.email} - - - {user.lang_key ? langLabel(user.lang_key) : t('team.langDefault')} - -
-
- - -
    -
  • - {t('team.drawer.status')}: -
  • -
  • - - {t('team.drawer.tfa')}:{' '} - {user.tfa_enabled ? ( - - {t('team.drawer.tfaEnabled')} - - ) : ( - t('team.drawer.tfaNotConfigured') - )} - - {user.tfa_enabled && ( - - )} -
  • - {isPending && ( -
  • - {t('team.drawer.pendingNote')} -
  • - )} - {user.federated &&
  • {t('team.drawer.federatedNote')}
  • } - {user.updated_at && ( -
  • {t('team.drawer.lastModified', { time: relativeTime(user.updated_at, t) })}
  • - )} -
-
- - - - {roleNames.length === 0 && ( -

- {t('team.drawer.noRoleWarning')} -

- )} -
-
- -
- {user.status === 'active' ? ( - - ) : isPending ? ( - {t('team.drawer.waitingInvite')} - ) : ( - - )} -
- - -
-
- - {confirmResetTfa && ( -
!busy && setConfirmResetTfa(false)} - > -
e.stopPropagation()} - > -
- -

{t('team.drawer.resetTfaConfirmTitle')}

-
-
- {t('team.drawer.resetTfaConfirmBody', { name: fullName(user) })} -
-
- - -
-
-
- )} - - )} -
-
- ) -} - -/* ───────────────────────────────────────────────────────────────────────── - * Invite dialog — create user (no password; invite-by-email like the backend) - * ───────────────────────────────────────────────────────────────────────── */ - -function InviteDialog({ - roles, - onClose, - onCreated, -}: { - roles: Role[] - onClose: () => void - onCreated: () => void -}) { - const { t } = useTranslation() - const [email, setEmail] = useState('') - const [name, setName] = useState('') - const [roleNames, setRoleNames] = useState(['ROLE_VIEWER']) - const [busy, setBusy] = useState(false) - - const valid = /.+@.+\..+/.test(email.trim()) - - const toggleRole = (name: string) => - setRoleNames((rs) => (rs.includes(name) ? rs.filter((r) => r !== name) : [...rs, name])) - - const submit = async () => { - if (!valid || busy) return - setBusy(true) - try { - await usersHttpService.create({ - email: email.trim(), - name: name.trim() || undefined, - // No lang_key: the user inherits the platform-default language until they - // pick their own in their profile. - role_names: roleNames, - }) - toast.success(t('team.toast.invitationSent')) - onCreated() - } catch (err) { - toast.error(userError(err, t)) - } finally { - setBusy(false) - } - } - - return ( -
-
e.stopPropagation()} - > -
-
-

- - {t('team.invite.title')} -

-

{t('team.invite.description')}

-
- -
- -
-
- - setEmail(e.target.value)} - placeholder="jsmith@company.com" - /> - - - setName(e.target.value)} placeholder="Jane Smith" /> - -
-
- - -
-
- -
- - -
-
-
- ) -} - -function RolePicker({ - roles, - selected, - onToggle, - compact, -}: { - roles: Role[] - selected: string[] - onToggle: (name: string) => void - compact?: boolean -}) { - const { t } = useTranslation() - if (roles.length === 0) { - return

{t('team.rolePicker.none')}

- } - return ( -
- {roles.map((r) => { - const checked = selected.includes(r.name) - const isAdmin = r.name === 'ROLE_ADMIN' - return ( - - ) - })} -
- ) -} - -/* ───────────────────────────────────────────────────────────────────────── - * Roles view — read-only catalog + permission matrix (loaded from the backend) - * ───────────────────────────────────────────────────────────────────────── */ - -function RolesView({ roles, onChanged }: { roles: Role[]; onChanged: () => void }) { - const { t } = useTranslation() - const [details, setDetails] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - const [editing, setEditing] = useState(null) - const [deleting, setDeleting] = useState(null) - - useEffect(() => { - if (roles.length === 0) return - let cancelled = false - setLoading(true) - setError(false) - Promise.all(roles.map((r) => rolesHttpService.get(r.id))) - .then((d) => { - if (!cancelled) setDetails(d) - }) - .catch(() => { - if (!cancelled) setError(true) - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - return () => { - cancelled = true - } - }, [roles]) - - // Union of all permissions across roles, grouped by resource (rows of the matrix). - const byResource = useMemo(() => { - const map = new Map>() - details?.forEach((d) => - d.permissions.forEach((p) => { - const resource = permResource(p.name) - if (!map.has(resource)) map.set(resource, new Map()) - map.get(resource)!.set(p.name, p) - }) - ) - return [...map.entries()] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([resource, perms]) => ({ - resource, - perms: [...perms.values()].sort((a, b) => a.name.localeCompare(b.name)), - })) - }, [details]) - - const has = (roleName: string, permName: string) => - details?.find((d) => d.name === roleName)?.permissions.some((p) => p.name === permName) ?? false - - if (loading) { - return ( -
- - {t('team.roles.loading')} -
- ) - } - if (error || !details) { - return ( -
- {t('team.roles.loadFailed')} -
- ) - } - - return ( -
-
- {t('team.roles.systemNote')} - -
- -
- {details.map((d) => ( -
-
- {d.name === 'ROLE_ADMIN' ? ( - - ) : ( - - )} -
-
- {roleLabel(t, d.name, d.display_name)} - {d.system && ( - - - {t('team.roles.systemBadge')} - - )} -
- {d.name} -
- {!d.system && ( -
- - -
- )} -
- {roleDesc(t, d.name, d.description) && ( -

{roleDesc(t, d.name, d.description)}

- )} -
- - - {t('team.roles.permissionsCount', { count: d.permissions.length })} - - · - - {t('team.roles.modulesCount', { - count: new Set(d.permissions.map((p) => permResource(p.name))).size, - })} - -
-
- ))} -
- -
-
-

{t('team.roles.matrixTitle')}

-

{t('team.roles.matrixSubtitle')}

-
-
-
{t('team.roles.permission')}
- {details.map((d) => ( -
- {roleLabel(t, d.name, d.display_name)} -
- ))} -
- {byResource.map(({ resource, perms }) => ( -
-
- {resourceLabel(t, resource)} -
- {perms.map((p) => ( -
-
- {p.name} -
- {p.description || actionLabel(t, permAction(p.name))} -
-
- {details.map((d) => ( - - ))} -
- ))} -
- ))} -
- - {editing && ( - setEditing(null)} - onSaved={() => { - setEditing(null) - onChanged() - }} - /> - )} - {deleting && ( - setDeleting(null)} - onDeleted={() => { - setDeleting(null) - onChanged() - }} - /> - )} -
- ) -} - -/* ───────────────────────────────────────────────────────────────────────── - * Role editor — create a tenant role, or edit one that isn't a system role - * ───────────────────────────────────────────────────────────────────────── */ - -function RoleEditor({ - role, - onClose, - onSaved, -}: { - role: RoleDetail | null - onClose: () => void - onSaved: () => void -}) { - const { t } = useTranslation() - const [catalog, setCatalog] = useState(null) - const [name, setName] = useState(role?.name ?? 'ROLE_') - const [displayName, setDisplayName] = useState(role?.display_name ?? '') - const [description, setDescription] = useState(role?.description ?? '') - const [selected, setSelected] = useState((role?.permissions ?? []).map((p) => p.name)) - const [busy, setBusy] = useState(false) - - useEffect(() => { - let cancelled = false - rolesHttpService - .listPermissions() - .then((p) => { - if (!cancelled) setCatalog(p) - }) - .catch(() => { - if (!cancelled) toast.error(t('team.toast.permissionsLoadFailed')) - }) - return () => { - cancelled = true - } - }, [t]) - - const byResource = useMemo(() => { - const map = new Map() - catalog?.forEach((p) => { - const resource = permResource(p.name) - if (!map.has(resource)) map.set(resource, []) - map.get(resource)!.push(p) - }) - return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])) - }, [catalog]) - - const valid = name.trim().length >= 2 && name.trim().length <= 50 - - const toggle = (perm: string) => - setSelected((s) => (s.includes(perm) ? s.filter((p) => p !== perm) : [...s, perm])) - - const toggleResource = (perms: Permission[]) => { - const names = perms.map((p) => p.name) - const allOn = names.every((n) => selected.includes(n)) - setSelected((s) => (allOn ? s.filter((p) => !names.includes(p)) : [...new Set([...s, ...names])])) - } - - const submit = async () => { - if (!valid || busy) return - setBusy(true) - try { - const body = { - name: name.trim(), - display_name: displayName.trim() || undefined, - description: description.trim() || undefined, - permissions: selected, - } - if (role) { - await rolesHttpService.update(role.id, body) - toast.success(t('team.toast.roleUpdated')) - } else { - await rolesHttpService.create(body) - toast.success(t('team.toast.roleCreated')) - } - onSaved() - } catch (err) { - toast.error(roleError(err, t)) - } finally { - setBusy(false) - } - } - - return ( -
-
e.stopPropagation()} - > -
-
-

- - {role ? t('team.roles.editTitle') : t('team.roles.createTitle')} -

-

{t('team.roles.editorSubtitle')}

-
- -
- -
-
- - setName(e.target.value)} - className="font-mono" - placeholder="ROLE_AUDITOR" - /> - - - setDisplayName(e.target.value)} placeholder="Auditor" /> - -
- - setDescription(e.target.value)} /> - - -
-
- - - {t('team.roles.selectedCount', { count: selected.length })} - -
- {catalog === null ? ( -
- - {t('team.roles.loadingPermissions')} -
- ) : ( -
- {byResource.map(([resource, perms]) => ( -
- -
- {perms.map((p) => { - const on = selected.includes(p.name) - return ( - - ) - })} -
-
- ))} -
- )} -
-
- -
- - -
-
-
- ) -} - -function DeleteRoleDialog({ - role, - onClose, - onDeleted, -}: { - role: RoleDetail - onClose: () => void - onDeleted: () => void -}) { - const { t } = useTranslation() - const [busy, setBusy] = useState(false) - - const remove = async () => { - setBusy(true) - try { - await rolesHttpService.remove(role.id) - toast.success(t('team.toast.roleDeleted')) - onDeleted() - } catch (err) { - toast.error(roleError(err, t)) - } finally { - setBusy(false) - } - } - - return ( -
!busy && onClose()} - > -
e.stopPropagation()} - > -
- -

{t('team.roles.deleteConfirmTitle')}

-
-
- {t('team.roles.deleteConfirmBody', { name: roleLabel(t, role.name, role.display_name) })} -
-
- - -
-
-
- ) -} - -function matrixCols(n: number): string { - return `1fr ${Array.from({ length: n }, () => '110px').join(' ')}` -} - -function PermTick({ on }: { on: boolean }) { - return ( -
- {on ? ( - - ) : ( - — - )} -
- ) -} - -/* ───────────────────────────────────────────────────────────────────────── - * Small shared parts + helpers - * ───────────────────────────────────────────────────────────────────────── */ - -function DrawerSection({ - title, - subtitle, - children, -}: { - title: string - subtitle?: string - children: React.ReactNode -}) { - return ( -
-
-

{title}

- {subtitle &&

{subtitle}

} -
-
{children}
-
- ) -} - -function DrawerField({ - label, - hint, - children, -}: { - label: string - hint?: string - children: React.ReactNode -}) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ) -} - -function InfoRow({ k, children }: { k: string; children: React.ReactNode }) { - return ( - <> -
{k}
-
{children}
- - ) -} - -function langLabel(code: string): string { - return SUPPORTED_LANGUAGES.find((l) => l.code === code)?.label ?? code -} - -// An account can exist before anyone has typed a display name into it — every -// federated one starts that way — so the address is what is always there. -function fullName(u: UserBase): string { - return u.name?.trim() || u.email -} - -function initials(u: UserBase): string { - return ( - fullName(u) - .split(/[\s.@_-]+/) - .map((p) => p[0]) - .filter(Boolean) - .slice(0, 2) - .join('') - .toUpperCase() || 'U' - ) -} - -function relativeTime(iso: string, t: TFunction): string { - if (!iso) return '—' - const diff = Date.now() - new Date(iso).getTime() - const m = Math.round(diff / 60_000) - if (m < 1) return t('team.relative.justNow') - if (m < 60) return t('team.relative.minutesAgo', { count: m }) - const h = Math.round(m / 60) - if (h < 24) return t('team.relative.hoursAgo', { count: h }) - const d = Math.round(h / 24) - if (d < 30) return t('team.relative.daysAgo', { count: d }) - return new Date(iso).toLocaleDateString() -} - -function roleError(err: unknown, t: TFunction): string { - if (err instanceof TeamHttpError) { - if (err.status === 409) return t('team.toast.roleNameInUse') - // The backend refuses to touch a seeded role whatever the request says. - if (err.status === 403) return t('team.toast.roleImmutable') - if (err.status === 400) return err.message || t('team.toast.invalidRequest') - } - return err instanceof Error ? err.message : t('team.toast.operationFailed') -} - -function userError(err: unknown, t: TFunction): string { - if (err instanceof TeamHttpError) { - if (err.status === 409) return t('team.toast.emailInUse') - if (err.status === 400) return err.message || t('team.toast.invalidRequest') - if (err.status === 403) return t('team.toast.noPermission') - if (err.status === 404) return t('team.toast.userNotFound') - } - return err instanceof Error ? err.message : t('team.toast.operationFailed') + ); } diff --git a/frontend/src/features/team/types/team.types.ts b/frontend/src/features/team/types/team.types.ts index a9abc14b9..037213b0f 100644 --- a/frontend/src/features/team/types/team.types.ts +++ b/frontend/src/features/team/types/team.types.ts @@ -58,6 +58,7 @@ export interface CreateUserRequest { name?: string lang_key?: string role_names?: string[] + password?:string } export interface UpdateUserRequest { diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index 0fd06ddc4..5f8934448 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -829,7 +829,8 @@ "roleDeleted": "Rolle gelöscht", "roleNameInUse": "Eine Rolle mit dieser Kennung existiert bereits", "roleImmutable": "Systemrollen können nicht geändert werden", - "permissionsLoadFailed": "Berechtigungen konnten nicht geladen werden" + "permissionsLoadFailed": "Berechtigungen konnten nicht geladen werden", + "localUserCreated": "Lokaler Benutzer erstellt" }, "members": { "searchPlaceholder": "Nach Name oder E-Mail suchen…", @@ -906,7 +907,20 @@ "sending": "Wird gesendet…", "send": "Einladung senden", "name": "Name", - "emailHint": "Mit dieser Adresse meldet sich die Person an." + "emailHint": "Mit dieser Adresse meldet sich die Person an.", + "localDescription": "Das Konto wird mit einem Passwort erstellt und sofort aktiviert. Es wird keine E-Mail gesendet.", + "modeInvite": "Benutzer einladen", + "modeLocal": "Lokalen Benutzer erstellen", + "passwordLabel": "Passwort", + "passwordPlaceholder": "Starkes Passwort festlegen", + "localUserNote": "Dieser Benutzer erhält keine E-Mail und wird automatisch aktiviert.", + "createLocal": "Benutzer erstellen", + "passwordRules": { + "length": "Mindestens 8 Zeichen", + "case": "Mindestens ein Groß- und ein Kleinst Buchstabe", + "special": "Mindestens ein Sonderzeichen" + }, + "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein" }, "rolePicker": { "none": "Keine Rollen verfügbar." diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index 7ac985fe6..0398a05ea 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -981,7 +981,8 @@ "roleDeleted": "Role deleted", "roleNameInUse": "A role with that identifier already exists", "roleImmutable": "System roles cannot be modified", - "permissionsLoadFailed": "Could not load permissions" + "permissionsLoadFailed": "Could not load permissions", + "localUserCreated": "Local user created" }, "members": { "searchPlaceholder": "Search by name or email…", @@ -1058,7 +1059,20 @@ "sending": "Sending…", "send": "Send invite", "name": "Name", - "emailHint": "This is the address they sign in with." + "emailHint": "This is the address they sign in with.", + "localDescription": "The account is created with a password and activated immediately. No email is sent.", + "modeInvite": "Invite user", + "modeLocal": "Create local user", + "passwordLabel": "Password", + "passwordPlaceholder": "Set a strong password", + "localUserNote": "This user will not receive an email and will be automatically activated.", + "createLocal": "Create user", + "passwordRules": { + "length": "At least 8 characters", + "case": "At least one uppercase and one lowercase letter", + "special": "At least one special character" + }, + "emailInvalid": "Please enter a valid email address" }, "rolePicker": { "none": "No roles available." diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index c86925d4a..5080f975c 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -829,7 +829,8 @@ "roleDeleted": "Rol eliminado", "roleNameInUse": "Ya existe un rol con ese identificador", "roleImmutable": "Los roles del sistema no se pueden modificar", - "permissionsLoadFailed": "No se pudieron cargar los permisos" + "permissionsLoadFailed": "No se pudieron cargar los permisos", + "localUserCreated": "Usuario local creado" }, "members": { "searchPlaceholder": "Buscar por nombre o correo…", @@ -906,7 +907,20 @@ "sending": "Enviando…", "send": "Enviar invitación", "name": "Nombre", - "emailHint": "Es la dirección con la que iniciará sesión." + "emailHint": "Es la dirección con la que iniciará sesión.", + "localDescription": "La cuenta se crea con una contraseña y se activa de inmediato. No se envía ningún correo.", + "modeInvite": "Invitar usuario", + "modeLocal": "Crear usuario local", + "passwordLabel": "Contraseña", + "passwordPlaceholder": "Establezca una contraseña segura", + "localUserNote": "Este usuario no recibirá un correo y será activado automáticamente.", + "createLocal": "Crear usuario", + "passwordRules": { + "length": "Al menos 8 caracteres", + "case": "Al menos una mayúscula y una minúscula", + "special": "Al menos un carácter especial" + }, + "emailInvalid": "Introduzca una dirección de correo válida" }, "rolePicker": { "none": "No hay roles disponibles." diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index cd915159a..b34839e4b 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -829,7 +829,8 @@ "roleDeleted": "Rôle supprimé", "roleNameInUse": "Un rôle avec cet identifiant existe déjà", "roleImmutable": "Les rôles système ne peuvent pas être modifiés", - "permissionsLoadFailed": "Impossible de charger les permissions" + "permissionsLoadFailed": "Impossible de charger les permissions", + "localUserCreated": "Utilisateur local créé" }, "members": { "searchPlaceholder": "Rechercher par nom ou e-mail…", @@ -906,7 +907,20 @@ "sending": "Envoi…", "send": "Envoyer l'invitation", "name": "Nom", - "emailHint": "C'est l'adresse utilisée pour se connecter." + "emailHint": "C'est l'adresse utilisée pour se connecter.", + "localDescription": "Le compte est créé avec un mot de passe et activé immédiatement. Aucun e-mail n'est envoyé.", + "modeInvite": "Inviter un utilisateur", + "modeLocal": "Créer un utilisateur local", + "passwordLabel": "Mot de passe", + "passwordPlaceholder": "Définir un mot de passe robuste", + "localUserNote": "Cet utilisateur ne recevra pas d'e-mail et sera activé automatiquement.", + "createLocal": "Créer l'utilisateur", + "passwordRules": { + "length": "Au moins 8 caractères", + "case": "Au moins une majuscule et une minuscule", + "special": "Au moins un caractère spécial" + }, + "emailInvalid": "Veuillez saisir une adresse e-mail valide" }, "rolePicker": { "none": "Aucun rôle disponible." diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index 136a7d97d..334e9486f 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -829,7 +829,8 @@ "roleDeleted": "Ruolo eliminato", "roleNameInUse": "Esiste già un ruolo con questo identificatore", "roleImmutable": "I ruoli di sistema non possono essere modificati", - "permissionsLoadFailed": "Impossibile caricare i permessi" + "permissionsLoadFailed": "Impossibile caricare i permessi", + "localUserCreated": "Utente locale creato" }, "members": { "searchPlaceholder": "Cerca per nome o e-mail…", @@ -906,7 +907,20 @@ "sending": "Invio…", "send": "Invia invito", "name": "Nome", - "emailHint": "È l'indirizzo con cui accederà." + "emailHint": "È l'indirizzo con cui accederà.", + "localDescription": "L'account viene creato con una password e attivato immediatamente. Non viene inviata alcuna e-mail.", + "modeInvite": "Invita utente", + "modeLocal": "Crea utente locale", + "passwordLabel": "Password", + "passwordPlaceholder": "Imposta una password forte", + "localUserNote": "Questo utente non riceverà un'e-mail e verrà attivato automaticamente.", + "createLocal": "Crea utente", + "passwordRules": { + "length": "Almeno 8 caratteri", + "case": "Almeno un carattere maiuscolo e uno minuscolo", + "special": "Almeno un carattere speciale" + }, + "emailInvalid": "Inserisci un indirizzo e-mail valido" }, "rolePicker": { "none": "Nessun ruolo disponibile." diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index f4e151706..297cdfed3 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -829,7 +829,8 @@ "roleDeleted": "Função excluída", "roleNameInUse": "Já existe uma função com esse identificador", "roleImmutable": "Funções de sistema não podem ser modificadas", - "permissionsLoadFailed": "Não foi possível carregar as permissões" + "permissionsLoadFailed": "Não foi possível carregar as permissões", + "localUserCreated": "Usuário local criado" }, "members": { "searchPlaceholder": "Pesquisar por nome ou e-mail…", @@ -906,7 +907,20 @@ "sending": "Enviando…", "send": "Enviar convite", "name": "Nome", - "emailHint": "É o endereço com o qual fará login." + "emailHint": "É o endereço com o qual fará login.", + "localDescription": "A conta é criada com uma senha e ativada imediatamente. Nenhum e-mail é enviado.", + "modeInvite": "Convidar usuário", + "modeLocal": "Criar usuário local", + "passwordLabel": "Senha", + "passwordPlaceholder": "Defina uma senha forte", + "localUserNote": "Este usuário não receberá um e-mail e será ativado automaticamente.", + "createLocal": "Criar usuário", + "passwordRules": { + "length": "Pelo menos 8 caracteres", + "case": "Pelo menos uma letra maiúscula e uma minúscula", + "special": "Pelo menos um caractere especial" + }, + "emailInvalid": "Insira um endereço de e-mail válido" }, "rolePicker": { "none": "Nenhuma função disponível." diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index 6507b8ad5..8059fc510 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -833,7 +833,8 @@ "roleDeleted": "Роль удалена", "roleNameInUse": "Роль с таким идентификатором уже существует", "roleImmutable": "Системные роли нельзя изменять", - "permissionsLoadFailed": "Не удалось загрузить разрешения" + "permissionsLoadFailed": "Не удалось загрузить разрешения", + "localUserCreated": "Локальный пользователь создан" }, "members": { "searchPlaceholder": "Поиск по имени или почте…", @@ -912,7 +913,20 @@ "sending": "Отправка…", "send": "Отправить приглашение", "name": "Имя", - "emailHint": "С этого адреса пользователь будет входить." + "emailHint": "С этого адреса пользователь будет входить.", + "localDescription": "Учётная запись создаётся с паролем и немедленно активируется. Письмо не отправляется.", + "modeInvite": "Пригласить пользователя", + "modeLocal": "Создать локального пользователя", + "passwordLabel": "Пароль", + "passwordPlaceholder": "Задайте надёжный пароль", + "localUserNote": "Этот пользователь не получит письмо и будет автоматически активирован.", + "createLocal": "Создать пользователя", + "passwordRules": { + "length": "Не менее 8 символов", + "case": "Хотя бы одна заглавная и одна строчная буква", + "special": "Хотя бы один специальный символ" + }, + "emailInvalid": "Введите корректный адрес электронной почты" }, "rolePicker": { "none": "Нет доступных ролей."