From 8624dbade3353098a932e2a6de1d697fc9fcb3a5 Mon Sep 17 00:00:00 2001 From: lekhrocks Date: Wed, 5 Aug 2026 21:02:03 +0530 Subject: [PATCH 1/3] feat(auth): force password change on first login + ADMIN-role RBAC - V10 migration: must_change_password on app_users - UserEntity/UserService: new accounts set mustChangePassword; changePassword() re-encodes and clears the flag - AuthService.login returns LoginResult(token, mustChangePassword) - AuthController: /login returns mustChangePassword; POST /auth/change-password sets a new password for the authenticated caller - PolicyResolver: grant full perms for the ADMIN role as well as the 'admin' username (overload accepts roles; AuthorizationService passes ctx.roles()) - RbacUnitTest: adminRoleGrantsAllPermissions; AuthServiceTest/UserServiceTest cover the flag and change flow --- .../com/syncflow/api/SyncFlowApplication.java | 2 +- .../api/controller/AuthController.java | 34 +++++++++++++-- .../syncflow/api/security/AuthService.java | 20 +++++++-- .../security/rbac/AuthorizationService.java | 2 +- .../api/security/rbac/PolicyResolver.java | 12 +++++- .../com/syncflow/api/user/UserService.java | 15 ++++++- .../syncflow/api/user/entity/UserEntity.java | 4 ++ .../migration/V10__must_change_password.sql | 3 ++ .../api/security/AuthServiceTest.java | 42 ++++++++++++++++--- .../syncflow/api/security/RbacUnitTest.java | 10 +++++ .../syncflow/api/user/UserServiceTest.java | 1 + 11 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 syncflow-api/src/main/resources/db/migration/V10__must_change_password.sql diff --git a/syncflow-api/src/main/java/com/syncflow/api/SyncFlowApplication.java b/syncflow-api/src/main/java/com/syncflow/api/SyncFlowApplication.java index fcd7bfb..865eb1a 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/SyncFlowApplication.java +++ b/syncflow-api/src/main/java/com/syncflow/api/SyncFlowApplication.java @@ -8,7 +8,7 @@ @EnableScheduling public class SyncFlowApplication { - public static void main(String[] args) { + static void main(String[] args) { SpringApplication.run(SyncFlowApplication.class, args); } } diff --git a/syncflow-api/src/main/java/com/syncflow/api/controller/AuthController.java b/syncflow-api/src/main/java/com/syncflow/api/controller/AuthController.java index 41a5bb8..d34462e 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/controller/AuthController.java +++ b/syncflow-api/src/main/java/com/syncflow/api/controller/AuthController.java @@ -1,8 +1,11 @@ package com.syncflow.api.controller; import com.syncflow.api.security.AuthService; +import com.syncflow.api.user.UserService; import com.syncflow.api.user.entity.UserEntity; import com.syncflow.api.user.repository.UserRepository; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; @@ -22,20 +25,30 @@ public class AuthController { private final AuthService authService; private final UserRepository userRepository; + private final UserService userService; - public AuthController(AuthService authService, UserRepository userRepository) { + public AuthController(AuthService authService, + UserRepository userRepository, + UserService userService) { this.authService = authService; this.userRepository = userRepository; + this.userService = userService; } public record LoginRequest(String username, String password) { } + public record ChangePasswordRequest(@NotBlank String newPassword) { + } + @PostMapping("/login") public ResponseEntity> login(@RequestBody LoginRequest req) { try { - var token = authService.login(req.username(), req.password()); - return ResponseEntity.ok(Map.of("token", token, "tokenType", "Bearer")); + var result = authService.login(req.username(), req.password()); + return ResponseEntity.ok(Map.of( + "token", result.token(), + "tokenType", "Bearer", + "mustChangePassword", result.mustChangePassword())); } catch (BadCredentialsException | DisabledException | LockedException e) { // Credential failures and disabled/locked accounts are all a 401 — do not // reveal which; a 500 would be wrong and leak that the account exists. @@ -43,6 +56,18 @@ public ResponseEntity> login(@RequestBody LoginRequest req) } } + /** + * Set a new password for the authenticated caller and clear the must-change + * flag. + */ + @PostMapping("/change-password") + public ResponseEntity> changePassword( + Authentication auth, + @Valid @RequestBody ChangePasswordRequest req) { + userService.changePassword(auth.getName(), req.newPassword()); + return ResponseEntity.ok(Map.of("updated", true)); + } + @GetMapping("/me") public ResponseEntity> me(Authentication auth) { var user = userRepository.findByUsername(auth.getName()) @@ -56,6 +81,7 @@ private Map toMap(UserEntity u) { "username", u.getUsername(), "email", u.getEmail() != null ? u.getEmail() : "", "roles", u.getRoles(), - "enabled", u.isEnabled()); + "enabled", u.isEnabled(), + "mustChangePassword", u.isMustChangePassword()); } } diff --git a/syncflow-api/src/main/java/com/syncflow/api/security/AuthService.java b/syncflow-api/src/main/java/com/syncflow/api/security/AuthService.java index db5039e..a5a940b 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/security/AuthService.java +++ b/syncflow-api/src/main/java/com/syncflow/api/security/AuthService.java @@ -1,6 +1,7 @@ package com.syncflow.api.security; import com.syncflow.api.config.JwtProperties; +import com.syncflow.api.user.repository.UserRepository; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; @@ -26,16 +27,26 @@ public class AuthService { private final AuthenticationManager authenticationManager; private final JwtEncoder jwtEncoder; private final JwtProperties jwtProperties; + private final UserRepository userRepository; public AuthService(AuthenticationManager authenticationManager, JwtEncoder jwtEncoder, - JwtProperties jwtProperties) { + JwtProperties jwtProperties, + UserRepository userRepository) { this.authenticationManager = authenticationManager; this.jwtEncoder = jwtEncoder; this.jwtProperties = jwtProperties; + this.userRepository = userRepository; } - public String login(String username, String password) { + /** + * Result of a successful login: the bearer token + whether the password must + * change. + */ + public record LoginResult(String token, boolean mustChangePassword) { + } + + public LoginResult login(String username, String password) { // authenticate() returns the populated principal (UserDetails) — carry the // roles from it instead of re-querying the user store. var auth = authenticationManager.authenticate( @@ -45,7 +56,10 @@ public String login(String username, String password) { .map(GrantedAuthority::getAuthority) .map(a -> a.startsWith("ROLE_") ? a.substring("ROLE_".length()) : a) .toList(); - return issueToken(user.getUsername(), roles); + var mustChangePassword = userRepository.findByUsername(user.getUsername()) + .map(u -> u.isMustChangePassword()) + .orElse(false); + return new LoginResult(issueToken(user.getUsername(), roles), mustChangePassword); } private String issueToken(String username, java.util.List roles) { diff --git a/syncflow-api/src/main/java/com/syncflow/api/security/rbac/AuthorizationService.java b/syncflow-api/src/main/java/com/syncflow/api/security/rbac/AuthorizationService.java index 339ea18..725e3d5 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/security/rbac/AuthorizationService.java +++ b/syncflow-api/src/main/java/com/syncflow/api/security/rbac/AuthorizationService.java @@ -26,7 +26,7 @@ public void require(ResourcePermission permission) { } public boolean isPermitted(ResourcePermission permission, TenantContext ctx) { - var policies = policyResolver.resolve(ctx.tenantId(), ctx.userId()); + var policies = policyResolver.resolve(ctx.tenantId(), ctx.userId(), ctx.roles()); return policies.contains(permission); } diff --git a/syncflow-api/src/main/java/com/syncflow/api/security/rbac/PolicyResolver.java b/syncflow-api/src/main/java/com/syncflow/api/security/rbac/PolicyResolver.java index 75b1571..fddc101 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/security/rbac/PolicyResolver.java +++ b/syncflow-api/src/main/java/com/syncflow/api/security/rbac/PolicyResolver.java @@ -4,16 +4,26 @@ import org.springframework.stereotype.Component; import java.util.EnumSet; +import java.util.Set; @Component public class PolicyResolver { + /** The role that grants workspace-admin (full) permissions. */ + public static final String ADMIN_ROLE = "ADMIN"; + public EnumSet resolve(TenantId tenantId, String userId) { + return resolve(tenantId, userId, Set.of()); + } + + public EnumSet resolve(TenantId tenantId, String userId, Set roles) { EnumSet permissions = EnumSet.of(ResourcePermission.METRICS_READ, ResourcePermission.PIPELINE_READ, ResourcePermission.CONNECTION_READ); permissions.addAll(ResourcePermission.developer()); - if (userId != null && userId.equals("admin")) { + boolean isAdmin = (userId != null && userId.equals("admin")) + || (roles != null && roles.contains(ADMIN_ROLE)); + if (isAdmin) { permissions.addAll(ResourcePermission.workspaceAdmin()); } diff --git a/syncflow-api/src/main/java/com/syncflow/api/user/UserService.java b/syncflow-api/src/main/java/com/syncflow/api/user/UserService.java index 639c1fd..70fa1fe 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/user/UserService.java +++ b/syncflow-api/src/main/java/com/syncflow/api/user/UserService.java @@ -40,11 +40,23 @@ public UserEntity create(String username, String password, String email, String u.setEmail(email); u.setRoles(roles == null || roles.isBlank() ? RoleConstants.USER : roles); u.setEnabled(true); + // Admin-provisioned accounts must set their own password on first login. + u.setMustChangePassword(true); u.setCreatedAt(now); u.setUpdatedAt(now); return repository.save(u); } + /** Set a new password and clear the must-change flag (first-login flow). */ + public UserEntity changePassword(String username, String newPassword) { + var u = repository.findByUsername(username) + .orElseThrow(() -> new NoSuchElementException("User not found: " + username)); + u.setPasswordHash(passwordEncoder.encode(newPassword)); + u.setMustChangePassword(false); + u.setUpdatedAt(Instant.now()); + return repository.save(u); + } + public UserEntity update(String id, String email, String roles, Boolean enabled) { var u = find(id); if (email != null) @@ -83,7 +95,8 @@ public Map toMap(UserEntity u) { "username", u.getUsername(), "email", u.getEmail() != null ? u.getEmail() : "", "roles", u.getRoles(), - "enabled", u.isEnabled()); + "enabled", u.isEnabled(), + "mustChangePassword", u.isMustChangePassword()); } /** Thrown when creating a user whose username already exists. */ diff --git a/syncflow-api/src/main/java/com/syncflow/api/user/entity/UserEntity.java b/syncflow-api/src/main/java/com/syncflow/api/user/entity/UserEntity.java index 4258e1a..4c37cc6 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/user/entity/UserEntity.java +++ b/syncflow-api/src/main/java/com/syncflow/api/user/entity/UserEntity.java @@ -35,6 +35,10 @@ public class UserEntity { @Column(nullable = false) private boolean enabled; + /** Admin-provisioned accounts must set their own password on first login. */ + @Column(name = "must_change_password", nullable = false) + private boolean mustChangePassword; + @Column(name = "created_at", nullable = false) private Instant createdAt; diff --git a/syncflow-api/src/main/resources/db/migration/V10__must_change_password.sql b/syncflow-api/src/main/resources/db/migration/V10__must_change_password.sql new file mode 100644 index 0000000..e2a4f7a --- /dev/null +++ b/syncflow-api/src/main/resources/db/migration/V10__must_change_password.sql @@ -0,0 +1,3 @@ +-- Force admin-provisioned accounts to set their own password on first login. +ALTER TABLE app_users + ADD COLUMN must_change_password BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/syncflow-api/src/test/java/com/syncflow/api/security/AuthServiceTest.java b/syncflow-api/src/test/java/com/syncflow/api/security/AuthServiceTest.java index af815a4..be6a5f8 100644 --- a/syncflow-api/src/test/java/com/syncflow/api/security/AuthServiceTest.java +++ b/syncflow-api/src/test/java/com/syncflow/api/security/AuthServiceTest.java @@ -5,6 +5,8 @@ import com.nimbusds.jose.jwk.OctetSequenceKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.syncflow.api.config.JwtProperties; +import com.syncflow.api.user.entity.UserEntity; +import com.syncflow.api.user.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.security.authentication.AuthenticationManager; @@ -30,11 +32,13 @@ class AuthServiceTest { private static final String SECRET = "c3luY2Zsb3ctaHMyNTYtand0LXNlY3JldC1rZXktMjAyNi1jaGFuZ2UtaW4tcHJvZA=="; private AuthenticationManager authenticationManager; + private UserRepository userRepository; private AuthService service; @BeforeEach void setUp() { authenticationManager = mock(AuthenticationManager.class); + userRepository = mock(UserRepository.class); var props = new JwtProperties(); props.setSecret(SECRET); props.setIssuer("syncflow"); @@ -44,7 +48,15 @@ void setUp() { .algorithm(JWSAlgorithm.HS256) .build(); JwtEncoder encoder = new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk))); - service = new AuthService(authenticationManager, encoder, props); + service = new AuthService(authenticationManager, encoder, props, userRepository); + } + + private UserEntity user(String username, boolean mustChange) { + var u = new UserEntity(); + u.setUsername(username); + u.setRoles("ADMIN"); + u.setMustChangePassword(mustChange); + return u; } @Test @@ -55,14 +67,31 @@ void loginIssuesJwtWithScopeClaim() { .build(); when(authenticationManager.authenticate(any())) .thenReturn(new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities())); + when(userRepository.findByUsername("admin")).thenReturn(java.util.Optional.of(user("admin", false))); - var token = service.login("admin", "pw"); - assertNotNull(token); - var parts = token.split("\\."); + var result = service.login("admin", "pw"); + assertNotNull(result.token()); + assertNotNull(result); + var parts = result.token().split("\\."); assertEquals(3, parts.length, "JWT should have three segments"); var claims = new String(Base64.getUrlDecoder().decode(parts[1])); assertTrue(claims.contains("syncflow"), "issuer should be present"); assertTrue(claims.contains("ADMIN"), "scope claim should carry roles"); + assertEquals(false, result.mustChangePassword()); + } + + @Test + void loginReportsMustChangePassword() { + var principal = User.withUsername("alice") + .password("pw") + .authorities("ROLE_USER") + .build(); + when(authenticationManager.authenticate(any())) + .thenReturn(new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities())); + when(userRepository.findByUsername("alice")).thenReturn(java.util.Optional.of(user("alice", true))); + + var result = service.login("alice", "pw"); + assertEquals(true, result.mustChangePassword(), "fresh admin-provisioned account must change password"); } @Test @@ -73,9 +102,10 @@ void scopesStripRolePrefix() { .build(); when(authenticationManager.authenticate(any())) .thenReturn(new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities())); + when(userRepository.findByUsername("bob")).thenReturn(java.util.Optional.of(user("bob", false))); - var token = service.login("bob", "pw"); - var claims = new String(Base64.getUrlDecoder().decode(token.split("\\.")[1])); + var result = service.login("bob", "pw"); + var claims = new String(Base64.getUrlDecoder().decode(result.token().split("\\.")[1])); assertTrue(claims.contains("\"USER\""), "scope should be bare role, not ROLE_-prefixed"); } } diff --git a/syncflow-api/src/test/java/com/syncflow/api/security/RbacUnitTest.java b/syncflow-api/src/test/java/com/syncflow/api/security/RbacUnitTest.java index 44db588..524f934 100644 --- a/syncflow-api/src/test/java/com/syncflow/api/security/RbacUnitTest.java +++ b/syncflow-api/src/test/java/com/syncflow/api/security/RbacUnitTest.java @@ -42,6 +42,16 @@ void adminHasAllPermissions() { assertTrue(authz.isPermitted(ResourcePermission.AI_USE, ctx)); } + @Test + void adminRoleGrantsAllPermissions() { + // A non-admin username with the ADMIN role gets full permissions. + var ctx = new TenantContext(TenantId.DEFAULT, null, null, null, "alice", + Set.of(PolicyResolver.ADMIN_ROLE), java.time.Instant.now()); + assertTrue(authz.isPermitted(ResourcePermission.AUDIT_READ, ctx)); + assertTrue(authz.isPermitted(ResourcePermission.AI_USE, ctx)); + assertTrue(authz.isPermitted(ResourcePermission.ORG_WRITE, ctx)); + } + @Test void tenantContextHolderRoundTrip() { var ctx = new TenantContext(TenantId.DEFAULT, null, null, null, "user", Set.of(), java.time.Instant.now()); diff --git a/syncflow-api/src/test/java/com/syncflow/api/user/UserServiceTest.java b/syncflow-api/src/test/java/com/syncflow/api/user/UserServiceTest.java index 4931b09..298663a 100644 --- a/syncflow-api/src/test/java/com/syncflow/api/user/UserServiceTest.java +++ b/syncflow-api/src/test/java/com/syncflow/api/user/UserServiceTest.java @@ -42,6 +42,7 @@ void createEncodesPasswordAndDefaultsRoles() { assertNotEquals("pw", encoder.encode("pw")); assertEquals(RoleConstants.USER, u.getRoles(), "blank roles should default to USER"); assertTrue(u.isEnabled()); + assertTrue(u.isMustChangePassword(), "admin-provisioned account must change password on first login"); } @Test From 77167c8e2a29ce9d31f2b48fd9c2e644a7f3e912 Mon Sep 17 00:00:00 2001 From: lekhrocks Date: Wed, 5 Aug 2026 21:02:30 +0530 Subject: [PATCH 2/3] feat(ui): login flow, user management, change-password, admin role gating - AuthContext: token in localStorage, bearer injection via setAuthToken, /auth/me hydration, roles/isAdmin derived from the user's roles - LoginPage: JWT login form - ChangePasswordPage: first-login password change for provisioned accounts - UsersPage: user list (roles/enabled), create modal, toggle, delete - authApi/userApi services + User/Login types - App.tsx: auth guard (login/change-password/loading gates), AdminRoute wrapper; AppLayout: adminOnly nav items hidden for non-ADMIN roles - main.tsx: wrap in AuthProvider --- syncflow-ui/src/App.tsx | 35 ++++- syncflow-ui/src/auth/AuthContext.tsx | 78 +++++++++++ .../src/components/layout/AppLayout.tsx | 29 ++-- syncflow-ui/src/main.tsx | 5 +- syncflow-ui/src/pages/ChangePasswordPage.tsx | 48 +++++++ syncflow-ui/src/pages/LoginPage.tsx | 48 +++++++ syncflow-ui/src/pages/UsersPage.tsx | 125 ++++++++++++++++++ syncflow-ui/src/services/api.ts | 34 +++++ syncflow-ui/src/types/api.ts | 30 +++++ 9 files changed, 419 insertions(+), 13 deletions(-) create mode 100644 syncflow-ui/src/auth/AuthContext.tsx create mode 100644 syncflow-ui/src/pages/ChangePasswordPage.tsx create mode 100644 syncflow-ui/src/pages/LoginPage.tsx create mode 100644 syncflow-ui/src/pages/UsersPage.tsx diff --git a/syncflow-ui/src/App.tsx b/syncflow-ui/src/App.tsx index a91a0e4..5810aa2 100644 --- a/syncflow-ui/src/App.tsx +++ b/syncflow-ui/src/App.tsx @@ -1,5 +1,10 @@ import { Routes, Route, Navigate } from 'react-router'; +import { Center, Loader } from '@mantine/core'; +import type { ReactNode } from 'react'; import { AppLayout } from './components/layout/AppLayout'; +import { LoginPage } from './pages/LoginPage'; +import { ChangePasswordPage } from './pages/ChangePasswordPage'; +import { useAuth } from './auth/AuthContext'; import { DashboardPage } from './pages/DashboardPage'; import { ConnectionsPage } from './pages/ConnectionsPage'; import { ConnectionDetailPage } from './pages/ConnectionDetailPage'; @@ -9,6 +14,7 @@ import { PipelineDesignPage } from './pages/PipelineDesignPage'; import { ExecutionPage } from './pages/ExecutionPage'; import { MonitoringPage } from './pages/MonitoringPage'; import { AuditPage } from './pages/AuditPage'; +import { UsersPage } from './pages/UsersPage'; import { DiagnosticsPage } from './pages/DiagnosticsPage'; import { AdminPage } from './pages/admin/AdminPage'; import { AgentFleetPage } from './pages/admin/AgentFleetPage'; @@ -16,7 +22,31 @@ import { MarketplacePage } from './pages/marketplace/MarketplacePage'; import { WorkflowPage } from './pages/workflow/WorkflowPage'; import { AnimatePresence } from 'framer-motion'; +/** Wraps admin-only routes; non-admins are redirected to the dashboard. */ +function AdminRoute({ children }: { children: ReactNode }) { + const { isAdmin } = useAuth(); + return isAdmin ? children : ; +} + export default function App() { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + if (user.mustChangePassword) { + return ; + } + return ( @@ -31,9 +61,10 @@ export default function App() { } /> } /> } /> + } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/syncflow-ui/src/auth/AuthContext.tsx b/syncflow-ui/src/auth/AuthContext.tsx new file mode 100644 index 0000000..ef025d8 --- /dev/null +++ b/syncflow-ui/src/auth/AuthContext.tsx @@ -0,0 +1,78 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { authApi, setAuthToken } from '../services/api'; +import type { UserResponse } from '../types/api'; + +const TOKEN_KEY = 'syncflow.token'; + +interface AuthContextValue { + user: UserResponse | null; + loading: boolean; + roles: string[]; + isAdmin: boolean; + login: (username: string, password: string) => Promise; + logout: () => void; + refreshUser: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const roles = user?.roles + ? user.roles.split(',').map((r) => r.trim()).filter(Boolean) + : []; + const isAdmin = roles.includes('ADMIN'); + + useEffect(() => { + const token = localStorage.getItem(TOKEN_KEY); + if (!token) { + setLoading(false); + return; + } + setAuthToken(token); + authApi + .me() + .then(setUser) + .catch(() => { + // Token invalid/expired — clear it. + localStorage.removeItem(TOKEN_KEY); + setAuthToken(null); + }) + .finally(() => setLoading(false)); + }, []); + + const login = useCallback(async (username: string, password: string) => { + const res = await authApi.login(username, password); + localStorage.setItem(TOKEN_KEY, res.token); + setAuthToken(res.token); + const me = await authApi.me(); + setUser(me); + }, []); + + const logout = useCallback(() => { + localStorage.removeItem(TOKEN_KEY); + setAuthToken(null); + setUser(null); + }, []); + + const refreshUser = useCallback(async () => { + const me = await authApi.me(); + setUser(me); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth must be used within AuthProvider'); + } + return ctx; +} diff --git a/syncflow-ui/src/components/layout/AppLayout.tsx b/syncflow-ui/src/components/layout/AppLayout.tsx index 2855a4c..4d9fd9b 100644 --- a/syncflow-ui/src/components/layout/AppLayout.tsx +++ b/syncflow-ui/src/components/layout/AppLayout.tsx @@ -1,6 +1,7 @@ -import { AppShell, Group, Text, ThemeIcon, UnstyledButton, Flex } from '@mantine/core'; -import { IconDashboard, IconPlugConnected, IconSchema, IconPipeline, IconPlayerPlay, IconChartLine, IconShieldCheck, IconReportAnalytics, IconSettings, IconPackage, IconHierarchy, IconCloud } from '@tabler/icons-react'; +import { AppShell, Group, Text, ThemeIcon, UnstyledButton, Flex, ActionIcon } from '@mantine/core'; +import { IconDashboard, IconPlugConnected, IconSchema, IconPipeline, IconPlayerPlay, IconChartLine, IconShieldCheck, IconReportAnalytics, IconSettings, IconPackage, IconHierarchy, IconCloud, IconUsers, IconLogout } from '@tabler/icons-react'; import { Outlet, useNavigate, useLocation } from 'react-router'; +import { useAuth } from '../../auth/AuthContext'; import { AiFloatingButton } from '../ai/AiFloatingButton'; const navItems = [ @@ -11,16 +12,19 @@ const navItems = [ { label: 'Execution', icon: IconPlayerPlay, path: '/execution' }, { label: 'Monitoring', icon: IconChartLine, path: '/monitoring' }, { label: 'Audit', icon: IconShieldCheck, path: '/audit' }, + { label: 'Users', icon: IconUsers, path: '/users', adminOnly: true }, { label: 'Diagnostics', icon: IconReportAnalytics, path: '/diagnostics' }, { label: 'Workflows', icon: IconHierarchy, path: '/workflows' }, - { label: 'Agents', icon: IconCloud, path: '/agents' }, - { label: 'Admin', icon: IconSettings, path: '/admin' }, + { label: 'Agents', icon: IconCloud, path: '/agents', adminOnly: true }, + { label: 'Admin', icon: IconSettings, path: '/admin', adminOnly: true }, { label: 'Plugins', icon: IconPackage, path: '/marketplace' }, ]; export function AppLayout() { const navigate = useNavigate(); const location = useLocation(); + const { isAdmin, logout } = useAuth(); + const visibleNav = navItems.filter((item) => !item.adminOnly || isAdmin); return ( - - - - - SyncFlow + + + + + + SyncFlow + + + + - {navItems.map((item) => { + {visibleNav.map((item) => { const active = location.pathname.startsWith(item.path); return ( - + + + diff --git a/syncflow-ui/src/pages/ChangePasswordPage.tsx b/syncflow-ui/src/pages/ChangePasswordPage.tsx new file mode 100644 index 0000000..4c991a8 --- /dev/null +++ b/syncflow-ui/src/pages/ChangePasswordPage.tsx @@ -0,0 +1,48 @@ +import { Paper, PasswordInput, Button, Stack, Text, Center, ThemeIcon, Title } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import { IconLock } from '@tabler/icons-react'; +import { useAuth } from '../auth/AuthContext'; +import { authApi } from '../services/api'; + +/** First-login password change for admin-provisioned accounts. */ +export function ChangePasswordPage() { + const { refreshUser, logout } = useAuth(); + + const form = useForm({ + initialValues: { newPassword: '', confirm: '' }, + validate: { + newPassword: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'), + confirm: (v, values) => (v === values.newPassword ? null : 'Passwords do not match'), + }, + }); + + return ( +
+ + + + + + Set your password + Your account was created by an administrator. Set your own password to continue. + +
{ + try { + await authApi.changePassword(v.newPassword); + await refreshUser(); + } catch { + notifications.show({ color: 'red', message: 'Failed to update password' }); + } + })}> + + + + + + +
+
+
+ ); +} diff --git a/syncflow-ui/src/pages/LoginPage.tsx b/syncflow-ui/src/pages/LoginPage.tsx new file mode 100644 index 0000000..873cebd --- /dev/null +++ b/syncflow-ui/src/pages/LoginPage.tsx @@ -0,0 +1,48 @@ +import { Paper, TextInput, PasswordInput, Button, Stack, Text, Center, ThemeIcon } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import axios from 'axios'; +import { IconPipeline } from '@tabler/icons-react'; +import { useAuth } from '../auth/AuthContext'; + +export function LoginPage() { + const { login } = useAuth(); + + const form = useForm({ + initialValues: { username: '', password: '' }, + validate: { + username: (v) => (v.length >= 1 ? null : 'Username is required'), + password: (v) => (v.length >= 1 ? null : 'Password is required'), + }, + }); + + return ( +
+ + + + + + SyncFlow + Sign in to continue + +
{ + try { + await login(v.username, v.password); + } catch (e) { + const msg = axios.isAxiosError(e) + ? (e.response?.data as { error?: string } | undefined)?.error + : undefined; + notifications.show({ color: 'red', message: msg ?? 'Invalid credentials' }); + } + })}> + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/syncflow-ui/src/pages/UsersPage.tsx b/syncflow-ui/src/pages/UsersPage.tsx new file mode 100644 index 0000000..abce494 --- /dev/null +++ b/syncflow-ui/src/pages/UsersPage.tsx @@ -0,0 +1,125 @@ +import { Table, Button, Group, Title, Text, Badge, ActionIcon, Modal, TextInput, Select, PasswordInput, Switch, Stack } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { userApi } from '../services/api'; +import { notifications } from '@mantine/notifications'; +import { useForm } from '@mantine/form'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; + +const ROLE_OPTIONS = [ + { value: 'ADMIN', label: 'ADMIN' }, + { value: 'USER', label: 'USER' }, +]; + +export function UsersPage() { + const [opened, { open, close }] = useDisclosure(false); + const queryClient = useQueryClient(); + + const { data: users } = useQuery({ + queryKey: ['users'], + queryFn: userApi.list, + }); + + const form = useForm({ + initialValues: { username: '', password: '', email: '', roles: 'USER' }, + validate: { + username: (v) => (v.length >= 3 ? null : 'Username must be at least 3 characters'), + password: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'), + }, + }); + + const createMutation = useMutation({ + mutationFn: userApi.create, + onSuccess: () => { + notifications.show({ color: 'green', message: 'User created' }); + queryClient.invalidateQueries({ queryKey: ['users'] }); + close(); + form.reset(); + }, + onError: (e) => { + const message = axios.isAxiosError(e) + ? (e.response?.data as { error?: string } | undefined)?.error + : undefined; + notifications.show({ color: 'red', message: message ?? 'Failed to create user' }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: userApi.delete, + onSuccess: () => { + notifications.show({ color: 'green', message: 'User deleted' }); + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + onError: () => notifications.show({ color: 'red', message: 'Failed to delete user' }), + }); + + const toggleEnabledMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + userApi.update(id, { enabled }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }), + }); + + return ( +
+ + Users + + + + + + + Username + Email + Roles + Enabled + + + + + {users?.map((u) => ( + + {u.username} + {u.email} + + + {u.roles.split(',').map((r) => ( + {r.trim()} + ))} + + + + toggleEnabledMutation.mutate({ id: u.id, enabled: ev.currentTarget.checked })} + aria-label={`toggle ${u.username}`} + /> + + + deleteMutation.mutate(u.id)} aria-label={`delete ${u.username}`}> + + + + + ))} + +
+ + +
createMutation.mutate(v))}> + + + + +