diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a830c70..1be362d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,15 @@ +# SyncFlow local stack. +# +# Required: postgres + app (the control plane needs its DB). +# The connector databases (mysql, mongodb, redis) and kafka are OPTIONAL — +# enable them when you exercise those connectors. Kafka is off by default in +# the app (syncflow.kafka.enabled); flip the app env below to use it. +# +# Run: docker compose up -d +# App: http://localhost:8080 (JWT login; default admin/admin-test-password) + services: + # ─── Required ───────────────────────────────────────────────────────────── postgres: image: postgres:16-alpine container_name: syncflow-postgres @@ -14,10 +25,10 @@ services: test: ["CMD-SHELL", "pg_isready -U syncflow"] interval: 5s timeout: 5s - retries: 5 + retries: 10 app: - image: eclipse-temurin:25-jdk-alpine + image: eclipse-temurin:25-jre-alpine container_name: syncflow-app depends_on: postgres: @@ -28,11 +39,97 @@ services: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/syncflow SPRING_DATASOURCE_USERNAME: syncflow SPRING_DATASOURCE_PASSWORD: syncflow - SPRING_PROFILES_ACTIVE: docker + SPRING_JPA_HIBERNATE_DDL_AUTO: validate + # JWT auth (must-change-password is on for admin-provisioned accounts). + SYNCFLOW_JWT_SECRET: ${SYNCFLOW_JWT_SECRET:-c3luY2Zsb3ctaHMyNTYtand0LXNlY3JldC1rZXktMjAyNi1jaGFuZ2UtaW4tcHJvZA==} + # Kafka transport (off by default). + SYNCFLOW_KAFKA_ENABLED: ${SYNCFLOW_KAFKA_ENABLED:-false} + SYNCFLOW_KAFKA_BOOTSTRAP_SERVERS: kafka:9092 volumes: - ../syncflow-api/build/libs/syncflow-api.jar:/app/app.jar working_dir: /app entrypoint: ["java", "--enable-preview", "-jar", "app.jar"] + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + + # ─── Optional: connector source databases ───────────────────────────────── + mysql: + image: mysql:8.4 + container_name: syncflow-mysql + profiles: ["connectors"] + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: inventory + MYSQL_USER: syncflow + MYSQL_PASSWORD: syncflow + ports: + - "3306:3306" + command: --binlog-format=ROW --server-id=1 # required for Debezium CDC + volumes: + - mysqldata:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 5s + timeout: 5s + retries: 10 + + mongodb: + image: mongo:7.0 + container_name: syncflow-mongodb + profiles: ["connectors"] + ports: + - "27017:27017" + volumes: + - mongodata:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: syncflow-redis + profiles: ["connectors"] + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + # ─── Optional: Kafka transport ──────────────────────────────────────────── + zookeeper: + image: confluentinc/cp-zookeeper:7.6.0 + container_name: syncflow-zookeeper + profiles: ["kafka"] + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + + kafka: + image: confluentinc/cp-kafka:7.6.0 + container_name: syncflow-kafka + profiles: ["kafka"] + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 volumes: pgdata: + mysqldata: + mongodata: \ No newline at end of file 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 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))}> + + + + +