Skip to content
Merged
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
66 changes: 66 additions & 0 deletions backend/modules/iam/dto/user.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package dto

import (
"fmt"
"time"

"unicode"
"unicode/utf8"
"github.com/utmstack/utmstack/backend/modules/iam/domain"
)

Expand All @@ -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"`
Expand Down
17 changes: 16 additions & 1 deletion backend/modules/iam/handler/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -92,6 +106,7 @@ func (h *UserHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, resp)
}


// @Summary Update user
// @Tags Users
// @Security BearerAuth
Expand Down
2 changes: 1 addition & 1 deletion backend/modules/iam/usecase/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions frontend/src/features/team/components/avatar.tsx
Original file line number Diff line number Diff line change
@@ -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<UserStatus, { badge: string; dot: string }> = {
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 (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
style.badge,
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", style.dot)} />
{t(`team.status.${status}`, { defaultValue: status })}
</span>
);
}

export function Avatar({
user: u,
size = 36,
}: {
user: UserBase;
size?: number;
}) {
if (u.image_url) {
return (
<img
src={u.image_url}
alt={fullName(u)}
className="shrink-0 rounded-full object-cover"
style={{ height: size, width: size }}
/>
);
}
return (
<span
className="flex shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground"
style={{ height: size, width: size }}
>
{initials(u)}
</span>
);
}
71 changes: 71 additions & 0 deletions frontend/src/features/team/components/delete-role-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={() => !busy && onClose()}
>
<div
className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-center gap-2 border-b border-border px-6 py-4">
<Trash2 size={17} className="text-red-500" />
<h2 className="text-base font-semibold">
{t("team.roles.deleteConfirmTitle")}
</h2>
</header>
<div className="px-6 py-5 text-sm text-muted-foreground">
{t("team.roles.deleteConfirmBody", {
name: roleLabel(t, role.name, role.display_name),
})}
</div>
<footer className="flex items-center justify-end gap-2 border-t border-border px-6 py-3">
<Button variant="outline" size="sm" disabled={busy} onClick={onClose}>
{t("team.drawer.cancel")}
</Button>
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => void remove()}
>
{busy ? t("team.drawer.saving") : t("team.roles.delete")}
</Button>
</footer>
</div>
</div>
);
}
Loading
Loading