1
0
forked from baron/baron-sso

Merge pull request 'feature/user-group2' (#268) from feature/user-group2 into dev

Reviewed-on: baron/baron-sso#268
This commit is contained in:
2026-02-13 16:06:51 +09:00
27 changed files with 431 additions and 110 deletions

View File

@@ -119,7 +119,7 @@ jobs:
cd userfront
if [ -d test ]; then
flutter test
flutter test --platform chrome test/locale_storage_web_test.dart
# flutter test --platform chrome test/locale_storage_platform_test.dart
else
echo "No userfront tests: skipping (test/ directory not found)."
fi

View File

@@ -17,6 +17,7 @@ import {
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
const summaryCards = [
{

View File

@@ -18,7 +18,6 @@ import {
approveTenant,
deleteTenant,
fetchTenant,
fetchTenantGroups,
updateTenant,
} from "../../../lib/adminApi";
@@ -36,17 +35,11 @@ export function TenantProfilePage() {
queryFn: () => fetchTenant(tenantId),
});
const groupsQuery = useQuery({
queryKey: ["tenant-groups", { limit: 100 }],
queryFn: () => fetchTenantGroups(100, 0),
});
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [tenantGroupId, setTenantGroupId] = useState("");
useEffect(() => {
if (tenantQuery.data) {
@@ -55,7 +48,6 @@ export function TenantProfilePage() {
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
setTenantGroupId(tenantQuery.data.tenantGroupId ?? "");
}
}, [tenantQuery.data]);
@@ -66,7 +58,6 @@ export function TenantProfilePage() {
slug,
description: description || undefined,
status,
tenantGroupId: tenantGroupId || undefined,
domains: domains
.split(",")
.map((d) => d.trim())
@@ -145,25 +136,6 @@ export function TenantProfilePage() {
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Tenant Group</Label>
<select
value={tenantGroupId}
onChange={(e) => setTenantGroupId(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value=""> </option>
{groupsQuery.data?.items.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.slug})
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
.
.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)

View File

@@ -139,6 +139,27 @@ export async function approveTenant(tenantId: string) {
return data;
}
export type TenantAdmin = {
id: string;
name: string;
email: string;
};
export async function fetchTenantAdmins(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/admins`,
);
return data;
}
export async function addTenantAdmin(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function removeTenantAdmin(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
// Group Management
export type GroupMember = {
id: string;

View File

@@ -108,10 +108,10 @@ function detectLocale(): Locale {
}
// eslint-disable-next-line import/no-unresolved
import enRaw from "../../../locales/en.toml?raw";
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../../../locales/ko.toml?raw";
import koRaw from "../locales/ko.toml?raw";
const translations: Record<Locale, TomlObject> = {
ko: parseToml(koRaw),

View File

@@ -355,8 +355,32 @@ title_with_code = "Title With Code"
type = "Type"
[msg.userfront.error.whitelist]
$normalizedCode = "$NormalizedCode"
"$normalizedCode" = "{{error}}"
settings_disabled = "Account settings are currently unavailable."
invalid_session = "Your session has expired. Please sign in again."
verification_required = "Additional verification is required. Please follow the instructions."
recovery_expired = "The recovery link has expired. Please request a new one."
recovery_invalid = "The recovery link is invalid."
rate_limited = "Too many requests. Please try again later."
not_found = "The requested page could not be found."
bad_request = "Please check your input."
password_or_email_mismatch = "Email or password does not match."
[msg.userfront.error.ory]
"$normalizedCode" = "{{error}}"
access_denied = "The user denied the consent request."
consent_required = "Consent is required to continue."
interaction_required = "Additional interaction is required. Please try again."
invalid_client = "Client authentication failed."
invalid_grant = "The authorization grant is invalid or expired."
invalid_request = "The request is invalid."
invalid_scope = "The requested scope is invalid."
login_required = "Login is required."
request_forbidden = "The request was forbidden."
server_error = "An authentication server error occurred."
temporarily_unavailable = "The authentication server is temporarily unavailable."
unauthorized_client = "The client is not authorized for this request."
unsupported_response_type = "The response type is not supported."
[msg.userfront.forgot]
description = "Description"
@@ -372,10 +396,10 @@ link_failed = "Link Failed"
link_send_failed = "Link Send Failed"
link_sent_email = "Link Sent Email"
link_sent_phone = "Link Sent Phone"
link_timeout = "Link Timeout"
no_account = "No Account"
link_timeout = "Time expired."
no_account = "New to Baron?"
oidc_failed = "OIDC Failed"
qr_expired = "QR Expired"
qr_expired = "Time expired."
qr_init_failed = "QR Init Failed"
qr_login_required = "QR Login Required"
token_missing = "Token Missing"
@@ -383,7 +407,7 @@ verification_failed = "Verification Failed"
[msg.userfront.login.link]
approved = "Approved"
helper = "Helper"
helper = "Sending you a login link"
missing_login_id = "Missing Login Id"
missing_phone = "Missing Phone"
resend_wait = "Resend Wait"
@@ -871,6 +895,9 @@ retry = "Retry"
save = "Save"
search = "Search"
show_more = "Show More"
language = "Language"
language_ko = "한국어"
language_en = "English"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "Theme Toggle"
@@ -1091,7 +1118,7 @@ subtitle = "Manage your applications"
[ui.userfront]
app_title = "App Title"
app_title = "Baron SW Portal"
[ui.userfront.app_label]
admin_console = "Admin Console"
@@ -1161,7 +1188,7 @@ signup = "Signup"
submit = "Submit"
[ui.userfront.login.field]
login_id = "Login Id"
login_id = "Emain or Phone Number"
password = "Password"
[ui.userfront.login.link]
@@ -1175,7 +1202,7 @@ title = "Title"
[ui.userfront.login.qr]
expired = "Expired"
refresh = "Refresh"
remaining = "Remaining"
remaining = "Remaining: {{time}}"
[ui.userfront.login.short_code]
digits = "Digits"
@@ -1184,9 +1211,9 @@ prefix = "Prefix"
submit = "Submit"
[ui.userfront.login.tabs]
link = "Link"
link = "Link/Code"
password = "Password"
qr = "QR"
qr = "QR Code"
[ui.userfront.login.unregistered]
action = "Action"
@@ -1311,6 +1338,6 @@ logout = "Logout"
overview = "Overview"
relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard"
tenant_groups = "Tenant Groups"
user_groups = "User Groups"
tenants = "Tenants"
users = "Users"

View File

@@ -355,8 +355,32 @@ title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}"
[msg.userfront.error.whitelist]
$normalizedCode = "에러가 계속되면 관리자에게 문의해주세요"
"$normalizedCode" = "{{error}}"
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
bad_request = "입력값을 확인해 주세요."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
[msg.userfront.error.ory]
"$normalizedCode" = "{{error}}"
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
@@ -372,10 +396,10 @@ link_failed = "오류: {{error}}"
link_send_failed = "전송 실패: {{error}}"
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
link_timeout = "로그인 요청 시간이 과되었습니다."
link_timeout = "시간이 과되었습니다."
no_account = "계정이 없으신가요?"
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
qr_expired = "QR 세션이 만료되었습니다."
qr_expired = "시간이 경과되었습니다."
qr_init_failed = "QR 초기화에 실패했습니다: {{error}}"
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
token_missing = "로그인 토큰을 확인할 수 없습니다."
@@ -871,6 +895,9 @@ retry = "다시 시도"
save = "저장"
search = "검색"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
@@ -1091,7 +1118,7 @@ subtitle = "Manage your applications"
[ui.userfront]
app_title = "Baron 로그인"
app_title = "Baron SW 포탈"
[ui.userfront.app_label]
admin_console = "Admin Console"
@@ -1311,6 +1338,6 @@ logout = "로그아웃"
overview = "개요"
relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드"
tenant_groups = "테넌트 그룹"
user_groups = "유저 그룹"
tenants = "테넌트"
users = "사용자"

View File

@@ -355,8 +355,32 @@ title_with_code = ""
type = ""
[msg.userfront.error.whitelist]
$normalizedCode = ""
"$normalizedCode" = ""
settings_disabled = ""
invalid_session = ""
verification_required = ""
recovery_expired = ""
recovery_invalid = ""
rate_limited = ""
not_found = ""
bad_request = ""
password_or_email_mismatch = ""
[msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = ""
consent_required = ""
interaction_required = ""
invalid_client = ""
invalid_grant = ""
invalid_request = ""
invalid_scope = ""
login_required = ""
request_forbidden = ""
server_error = ""
temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.forgot]
description = ""
@@ -668,7 +692,7 @@ logout = ""
overview = ""
relying_parties = ""
tenant_dashboard = ""
tenant_groups = ""
user_groups = ""
tenants = ""
users = ""
@@ -883,6 +907,9 @@ retry = ""
save = ""
search = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
@@ -1187,7 +1214,7 @@ title = ""
[ui.userfront.login.qr]
expired = ""
refresh = ""
remaining = ""
remaining = "Remaining: {{time}}"
[ui.userfront.login.short_code]
digits = ""

View File

@@ -68,18 +68,18 @@ type SignupRequest struct {
// User Profile Models
type UserProfileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
}

View File

@@ -1,16 +1,19 @@
package handler
import (
"baron-sso-backend/internal/service"
"runtime"
"time"
"github.com/gofiber/fiber/v2"
)
type AdminHandler struct{}
type AdminHandler struct {
Keto service.KetoService
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{}
func NewAdminHandler(keto service.KetoService) *AdminHandler {
return &AdminHandler{Keto: keto}
}
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {

View File

@@ -27,24 +27,33 @@ func (m *AsyncMockIdpProvider) Name() string { return "mock-idp" }
func (m *AsyncMockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) {
return &domain.IDPMetadata{}, nil
}
func (m *AsyncMockIdpProvider) UserExists(loginID string) (bool, error) {
args := m.Called(loginID)
return args.Bool(0), args.Error(1)
}
func (m *AsyncMockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
args := m.Called(user, password)
return args.String(0), args.Error(1)
}
func (m *AsyncMockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
return nil, nil
}
func (m *AsyncMockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, nil }
func (m *AsyncMockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
return nil, nil
}
func (m *AsyncMockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
return nil, nil
}
func (m *AsyncMockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
return nil, nil
}
func (m *AsyncMockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
return &domain.PasswordPolicy{MinLength: 12}, nil
}
@@ -52,6 +61,7 @@ func (m *AsyncMockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string
func (m *AsyncMockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
return nil, nil
}
func (m *AsyncMockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
return nil
}
@@ -74,15 +84,19 @@ func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error
func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
func (m *AsyncMockUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
return nil, nil
}
func (m *AsyncMockUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
return nil, nil
}
func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
return nil, 0, nil
}
@@ -95,10 +109,12 @@ func (m *AsyncMockRedisRepo) Set(key string, value string, expiration time.Durat
args := m.Called(key, value, expiration)
return args.Error(0)
}
func (m *AsyncMockRedisRepo) Get(key string) (string, error) {
args := m.Called(key)
return args.String(0), args.Error(1)
}
func (m *AsyncMockRedisRepo) Delete(key string) error {
args := m.Called(key)
return args.Error(0)
@@ -114,9 +130,11 @@ type AsyncMockTenantService struct {
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
return nil, nil
}
func (m *AsyncMockTenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
return nil, nil
}
func (m *AsyncMockTenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
args := m.Called(ctx, emailDomain)
if args.Get(0) == nil {
@@ -124,23 +142,28 @@ func (m *AsyncMockTenantService) GetTenantByDomain(ctx context.Context, emailDom
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *AsyncMockTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return nil, nil
}
func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
return nil, nil
}
func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil
}
func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil }
func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {}
func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {}
func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
return nil
}
func (m *AsyncMockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
return nil
}
func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
return nil, nil
}
@@ -153,15 +176,19 @@ func (m *AsyncMockKetoService) CreateRelation(ctx context.Context, namespace, ob
args := m.Called(ctx, namespace, object, relation, subject)
return args.Error(0)
}
func (m *AsyncMockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return nil
}
func (m *AsyncMockKetoService) CheckPermission(ctx context.Context, namespace, object, relation, subject string) (bool, error) {
return false, nil
}
func (m *AsyncMockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
return nil, nil
}
func (m *AsyncMockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
return nil, nil
}

View File

@@ -24,7 +24,7 @@ type DevHandler struct {
ConsentRepo repository.ClientConsentRepository
}
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler {
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService) *DevHandler {
return &DevHandler{
Hydra: service.NewHydraAdminService(),
Redis: redis,

View File

@@ -9,11 +9,12 @@ import (
)
type RelyingPartyHandler struct {
Service service.RelyingPartyService
Service service.RelyingPartyService
KratosAdmin *service.KratosAdminService
}
func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s}
func NewRelyingPartyHandler(s service.RelyingPartyService, kratos *service.KratosAdminService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s, KratosAdmin: kratos}
}
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {

View File

@@ -12,12 +12,19 @@ import (
)
type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
DB *gorm.DB
Service service.TenantService
Keto service.KetoService
KratosAdmin *service.KratosAdminService
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler {
return &TenantHandler{DB: db, Service: svc}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, kratos *service.KratosAdminService) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
Keto: keto,
KratosAdmin: kratos,
}
}
type tenantSummary struct {
@@ -301,6 +308,85 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"})
}
// Fetch admins from Keto
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "")
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
type adminInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
admins := []adminInfo{}
for _, rel := range relations {
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
// Fetch user details from Kratos
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err != nil {
admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"})
continue
}
name := ""
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
email := ""
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
admins = append(admins, adminInfo{
ID: userID,
Name: name,
Email: email,
})
}
return c.JSON(admins)
}
func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
}
if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
}
if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusNoContent)
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {

View File

@@ -23,12 +23,15 @@ type MockUserGroupService struct {
func (m *MockUserGroupService) Create(ctx context.Context, group *domain.UserGroup) error {
return m.Called(ctx, group).Error(0)
}
func (m *MockUserGroupService) Update(ctx context.Context, group *domain.UserGroup) error {
return m.Called(ctx, group).Error(0)
}
func (m *MockUserGroupService) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *MockUserGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
@@ -36,23 +39,29 @@ func (m *MockUserGroupService) Get(ctx context.Context, id string) (*domain.User
}
return args.Get(0).(*domain.UserGroup), args.Error(1)
}
func (m *MockUserGroupService) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.UserGroup), args.Error(1)
}
func (m *MockUserGroupService) AddMember(ctx context.Context, groupID, userID string) error {
return m.Called(ctx, groupID, userID).Error(0)
}
func (m *MockUserGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
return m.Called(ctx, groupID, userID).Error(0)
}
func (m *MockUserGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
args := m.Called(ctx, groupID)
return args.Get(0).([]domain.GroupRole), args.Error(1)
}
func (m *MockUserGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
return m.Called(ctx, groupID, tenantID, relation).Error(0)
}
func (m *MockUserGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
return m.Called(ctx, groupID, tenantID, relation).Error(0)
}
@@ -106,7 +115,7 @@ func TestUserGroupHandler_AddMember(t *testing.T) {
groupID := "g1"
userID := "u1"
body, _ := json.Marshal(map[string]string{"userId": userID})
mockSvc.On("AddMember", mock.Anything, groupID, userID).Return(nil)
req := httptest.NewRequest("POST", "/user-groups/g1/members", bytes.NewReader(body))

View File

@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// Fixed MockKetoService to match service.KetoService exactly if possible.
// Wait, middleware/rbac.go imports baron-sso-backend/internal/service.
// So I should use service.RelationTuple.

View File

@@ -18,12 +18,15 @@ type MockUserGroupRepository struct {
func (m *MockUserGroupRepository) Create(ctx context.Context, group *domain.UserGroup) error {
return m.Called(ctx, group).Error(0)
}
func (m *MockUserGroupRepository) Update(ctx context.Context, group *domain.UserGroup) error {
return m.Called(ctx, group).Error(0)
}
func (m *MockUserGroupRepository) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *MockUserGroupRepository) FindByID(ctx context.Context, id string) (*domain.UserGroup, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
@@ -31,6 +34,7 @@ func (m *MockUserGroupRepository) FindByID(ctx context.Context, id string) (*dom
}
return args.Get(0).(*domain.UserGroup), args.Error(1)
}
func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.UserGroup), args.Error(1)
@@ -45,16 +49,20 @@ func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) erro
func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
return nil, nil
}
func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
args := m.Called(ctx, ids)
return args.Get(0).([]domain.User), args.Error(1)
}
func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
return nil, 0, nil
}
@@ -68,19 +76,24 @@ func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
args := m.Called(ctx, ids)
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error {
return nil
}
@@ -180,17 +193,17 @@ func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
// We need a way to mock KratosAdminService but it's a struct, not an interface.
// For this POC test, we'll focus on the Keto and UserRepo parts.
// If needed, we can refactor KratosAdminService to an interface.
svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil)
groupID := "group-1"
mockRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, Name: "Test"}, nil)
tuples := []RelationTuple{
{Object: groupID, Relation: "members", SubjectID: "User:u1"},
}
mockKeto.On("ListRelations", mock.Anything, "UserGroup", groupID, "members", "").Return(tuples, nil)
// User u1 not in local DB
mockUserRepo.On("FindByIDs", mock.Anything, []string{"u1"}).Return([]domain.User{}, nil)

View File

@@ -108,10 +108,10 @@ function detectLocale(): Locale {
}
// eslint-disable-next-line import/no-unresolved
import enRaw from "../../../locales/en.toml?raw";
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../../../locales/ko.toml?raw";
import koRaw from "../locales/ko.toml?raw";
const translations: Record<Locale, TomlObject> = {
ko: parseToml(koRaw),

View File

@@ -355,8 +355,32 @@ title_with_code = "Title With Code"
type = "Type"
[msg.userfront.error.whitelist]
$normalizedCode = "$NormalizedCode"
"$normalizedCode" = "{{error}}"
settings_disabled = "Account settings are currently unavailable."
invalid_session = "Your session has expired. Please sign in again."
verification_required = "Additional verification is required. Please follow the instructions."
recovery_expired = "The recovery link has expired. Please request a new one."
recovery_invalid = "The recovery link is invalid."
rate_limited = "Too many requests. Please try again later."
not_found = "The requested page could not be found."
bad_request = "Please check your input."
password_or_email_mismatch = "Email or password does not match."
[msg.userfront.error.ory]
"$normalizedCode" = "{{error}}"
access_denied = "The user denied the consent request."
consent_required = "Consent is required to continue."
interaction_required = "Additional interaction is required. Please try again."
invalid_client = "Client authentication failed."
invalid_grant = "The authorization grant is invalid or expired."
invalid_request = "The request is invalid."
invalid_scope = "The requested scope is invalid."
login_required = "Login is required."
request_forbidden = "The request was forbidden."
server_error = "An authentication server error occurred."
temporarily_unavailable = "The authentication server is temporarily unavailable."
unauthorized_client = "The client is not authorized for this request."
unsupported_response_type = "The response type is not supported."
[msg.userfront.forgot]
description = "Description"
@@ -372,10 +396,10 @@ link_failed = "Link Failed"
link_send_failed = "Link Send Failed"
link_sent_email = "Link Sent Email"
link_sent_phone = "Link Sent Phone"
link_timeout = "Link Timeout"
no_account = "No Account"
link_timeout = "Time expired."
no_account = "New to Baron?"
oidc_failed = "OIDC Failed"
qr_expired = "QR Expired"
qr_expired = "Time expired."
qr_init_failed = "QR Init Failed"
qr_login_required = "QR Login Required"
token_missing = "Token Missing"
@@ -383,7 +407,7 @@ verification_failed = "Verification Failed"
[msg.userfront.login.link]
approved = "Approved"
helper = "Helper"
helper = "Sending you a login link"
missing_login_id = "Missing Login Id"
missing_phone = "Missing Phone"
resend_wait = "Resend Wait"
@@ -871,6 +895,9 @@ retry = "Retry"
save = "Save"
search = "Search"
show_more = "Show More"
language = "Language"
language_ko = "한국어"
language_en = "English"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "Theme Toggle"
@@ -1091,7 +1118,7 @@ subtitle = "Manage your applications"
[ui.userfront]
app_title = "App Title"
app_title = "Baron SW Portal"
[ui.userfront.app_label]
admin_console = "Admin Console"
@@ -1161,7 +1188,7 @@ signup = "Signup"
submit = "Submit"
[ui.userfront.login.field]
login_id = "Login Id"
login_id = "Emain or Phone Number"
password = "Password"
[ui.userfront.login.link]
@@ -1175,7 +1202,7 @@ title = "Title"
[ui.userfront.login.qr]
expired = "Expired"
refresh = "Refresh"
remaining = "Remaining"
remaining = "Remaining: {{time}}"
[ui.userfront.login.short_code]
digits = "Digits"
@@ -1184,9 +1211,9 @@ prefix = "Prefix"
submit = "Submit"
[ui.userfront.login.tabs]
link = "Link"
link = "Link/Code"
password = "Password"
qr = "QR"
qr = "QR Code"
[ui.userfront.login.unregistered]
action = "Action"
@@ -1311,6 +1338,6 @@ logout = "Logout"
overview = "Overview"
relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard"
tenant_groups = "Tenant Groups"
user_groups = "User Groups"
tenants = "Tenants"
users = "Users"

View File

@@ -355,8 +355,32 @@ title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}"
[msg.userfront.error.whitelist]
$normalizedCode = "에러가 계속되면 관리자에게 문의해주세요"
"$normalizedCode" = "{{error}}"
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
bad_request = "입력값을 확인해 주세요."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
[msg.userfront.error.ory]
"$normalizedCode" = "{{error}}"
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
@@ -372,10 +396,10 @@ link_failed = "오류: {{error}}"
link_send_failed = "전송 실패: {{error}}"
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
link_timeout = "로그인 요청 시간이 과되었습니다."
link_timeout = "시간이 과되었습니다."
no_account = "계정이 없으신가요?"
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
qr_expired = "QR 세션이 만료되었습니다."
qr_expired = "시간이 경과되었습니다."
qr_init_failed = "QR 초기화에 실패했습니다: {{error}}"
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
token_missing = "로그인 토큰을 확인할 수 없습니다."
@@ -871,6 +895,9 @@ retry = "다시 시도"
save = "저장"
search = "검색"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
@@ -1091,7 +1118,7 @@ subtitle = "Manage your applications"
[ui.userfront]
app_title = "Baron 로그인"
app_title = "Baron SW 포탈"
[ui.userfront.app_label]
admin_console = "Admin Console"
@@ -1311,6 +1338,6 @@ logout = "로그아웃"
overview = "개요"
relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드"
tenant_groups = "테넌트 그룹"
user_groups = "유저 그룹"
tenants = "테넌트"
users = "사용자"

View File

@@ -355,8 +355,32 @@ title_with_code = ""
type = ""
[msg.userfront.error.whitelist]
$normalizedCode = ""
"$normalizedCode" = ""
settings_disabled = ""
invalid_session = ""
verification_required = ""
recovery_expired = ""
recovery_invalid = ""
rate_limited = ""
not_found = ""
bad_request = ""
password_or_email_mismatch = ""
[msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = ""
consent_required = ""
interaction_required = ""
invalid_client = ""
invalid_grant = ""
invalid_request = ""
invalid_scope = ""
login_required = ""
request_forbidden = ""
server_error = ""
temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.forgot]
description = ""
@@ -668,7 +692,7 @@ logout = ""
overview = ""
relying_parties = ""
tenant_dashboard = ""
tenant_groups = ""
user_groups = ""
tenants = ""
users = ""
@@ -883,6 +907,9 @@ retry = ""
save = ""
search = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
@@ -1187,7 +1214,7 @@ title = ""
[ui.userfront.login.qr]
expired = ""
refresh = ""
remaining = ""
remaining = "Remaining: {{time}}"
[ui.userfront.login.short_code]
digits = ""

View File

@@ -61,6 +61,7 @@ services:
- "${ADMIN_PORT:-5173}:5173"
volumes:
- ./adminfront:/app
- ./locales:/locales
- /app/node_modules
networks:
- baron_net
@@ -80,6 +81,7 @@ services:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:
- ./devfront:/app
- ./locales:/locales
- /app/node_modules
networks:
- baron_net

View File

@@ -76,11 +76,16 @@ function parseTomlKeys(filePath) {
continue;
}
const key = line.slice(0, eqIndex).trim();
let key = line.slice(0, eqIndex).trim();
if (!key) {
continue;
}
// Strip quotes if present
if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
}
const fullKey = [...currentSection, key].join('.');
keys.add(fullKey);
}

View File

@@ -4,4 +4,8 @@ import 'locale_storage_stub.dart'
abstract class LocaleStorage {
static String? read() => localeStorage.read();
static void write(String locale) => localeStorage.write(locale);
static void forceMemoryStorageForTests(bool value) =>
localeStorage.forceMemoryStorageForTests(value);
static void forceSessionStorageForTests(bool value) =>
localeStorage.forceSessionStorageForTests(value);
}

View File

@@ -6,6 +6,14 @@ class LocaleStorageImpl {
void write(String locale) {
_locale = locale;
}
void forceMemoryStorageForTests(bool value) {
// Stub
}
void forceSessionStorageForTests(bool value) {
// Stub
}
}
final localeStorage = LocaleStorageImpl();

View File

@@ -11,7 +11,7 @@ class LocaleStorageImpl {
static bool _forceSession = false;
@visibleForTesting
static void forceMemoryStorageForTests(bool value) {
void forceMemoryStorageForTests(bool value) {
_forceMemory = value;
if (!value) {
_memory.clear();
@@ -19,7 +19,7 @@ class LocaleStorageImpl {
}
@visibleForTesting
static void forceSessionStorageForTests(bool value) {
void forceSessionStorageForTests(bool value) {
_forceSession = value;
}

View File

@@ -1,13 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/i18n/locale_storage.dart';
import 'package:userfront/core/i18n/locale_storage_web.dart' as locale_web;
import 'helpers/web_storage.dart';
void main() {
setUp(() {
locale_web.LocaleStorageImpl.forceMemoryStorageForTests(false);
locale_web.LocaleStorageImpl.forceSessionStorageForTests(false);
LocaleStorage.forceMemoryStorageForTests(false);
LocaleStorage.forceSessionStorageForTests(false);
if (webStorage.isWeb) {
webStorage.clear();
webStorage.clearSession();
@@ -15,8 +14,8 @@ void main() {
});
tearDown(() {
locale_web.LocaleStorageImpl.forceMemoryStorageForTests(false);
locale_web.LocaleStorageImpl.forceSessionStorageForTests(false);
LocaleStorage.forceMemoryStorageForTests(false);
LocaleStorage.forceSessionStorageForTests(false);
if (webStorage.isWeb) {
webStorage.clear();
webStorage.clearSession();
@@ -59,7 +58,7 @@ void main() {
return;
}
locale_web.LocaleStorageImpl.forceMemoryStorageForTests(true);
LocaleStorage.forceMemoryStorageForTests(true);
LocaleStorage.write('en');
expect(webStorage.get('locale'), isNull);
@@ -76,7 +75,7 @@ void main() {
return;
}
locale_web.LocaleStorageImpl.forceSessionStorageForTests(true);
LocaleStorage.forceSessionStorageForTests(true);
LocaleStorage.write('ko');
expect(webStorage.get('locale'), isNull);