Merge pull request 'feature/role-permission-system' (#6) from feature/role-permission-system into main
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2
.env
Normal file
2
.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE=http://backend:8000
|
||||||
|
VITE_WIZARD_PORT=8080
|
||||||
@@ -4,8 +4,6 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_WIZARD_PORT=18080
|
|
||||||
ENV VITE_WIZARD_PORT=$VITE_WIZARD_PORT
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage — lightweight static server, no nginx
|
# Production stage — lightweight static server, no nginx
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import api from '@/services/api'
|
import api from '@/services/api'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
interface Permission {
|
interface Permission {
|
||||||
id: number
|
id: number
|
||||||
@@ -11,18 +12,25 @@ interface Permission {
|
|||||||
interface Role {
|
interface Role {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string | null
|
||||||
is_global: boolean
|
is_global: boolean | null
|
||||||
permission_ids: number[]
|
permission_ids: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoleEditorPage() {
|
export default function RoleEditorPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
const [roles, setRoles] = useState<Role[]>([])
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
const [permissions, setPermissions] = useState<Permission[]>([])
|
const [permissions, setPermissions] = useState<Permission[]>([])
|
||||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null)
|
const [selectedRole, setSelectedRole] = useState<Role | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [newRoleName, setNewRoleName] = useState('')
|
||||||
|
const [newRoleDesc, setNewRoleDesc] = useState('')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const isAdmin = user?.is_admin === true
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
@@ -68,6 +76,46 @@ export default function RoleEditorPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteRole = async () => {
|
||||||
|
if (!selectedRole || !confirm(`Are you sure you want to delete the "${selectedRole.name}" role?`)) return
|
||||||
|
setSaving(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.delete(`/roles/${selectedRole.id}`)
|
||||||
|
setMessage('Role deleted successfully!')
|
||||||
|
setSelectedRole(null)
|
||||||
|
fetchData()
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage(err.response?.data?.detail || 'Failed to delete role')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDeleteRole = selectedRole && selectedRole.name !== 'admin' && isAdmin
|
||||||
|
|
||||||
|
const handleCreateRole = async () => {
|
||||||
|
if (!newRoleName.trim()) return
|
||||||
|
setCreating(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.post('/roles', {
|
||||||
|
name: newRoleName.trim(),
|
||||||
|
description: newRoleDesc.trim() || null,
|
||||||
|
is_global: false
|
||||||
|
})
|
||||||
|
setMessage('Role created successfully!')
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setNewRoleName('')
|
||||||
|
setNewRoleDesc('')
|
||||||
|
fetchData()
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage(err.response?.data?.detail || 'Failed to create role')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const groupedPermissions = permissions.reduce((acc, p) => {
|
const groupedPermissions = permissions.reduce((acc, p) => {
|
||||||
if (!acc[p.category]) acc[p.category] = []
|
if (!acc[p.category]) acc[p.category] = []
|
||||||
acc[p.category].push(p)
|
acc[p.category].push(p)
|
||||||
@@ -83,6 +131,74 @@ export default function RoleEditorPage() {
|
|||||||
Configure permissions for each role. Only admins can edit roles.
|
Configure permissions for each role. Only admins can edit roles.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{isAdmin && !showCreateForm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ marginBottom: '20px' }}
|
||||||
|
>
|
||||||
|
+ Create New Role
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateForm && (
|
||||||
|
<div style={{
|
||||||
|
border: '2px solid #007bff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
backgroundColor: '#f8f9fa'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Create New Role</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', maxWidth: '400px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 500 }}>
|
||||||
|
Role Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRoleName}
|
||||||
|
onChange={(e) => setNewRoleName(e.target.value)}
|
||||||
|
placeholder="e.g., developer, manager"
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 500 }}>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRoleDesc}
|
||||||
|
onChange={(e) => setNewRoleDesc(e.target.value)}
|
||||||
|
placeholder="Role description"
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateRole}
|
||||||
|
disabled={creating || !newRoleName.trim()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setNewRoleName('')
|
||||||
|
setNewRoleDesc('')
|
||||||
|
}}
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ padding: '8px 16px', borderRadius: '4px', border: '1px solid #ddd', background: '#fff' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
@@ -167,10 +283,27 @@ export default function RoleEditorPage() {
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
style={{ marginTop: '20px' }}
|
style={{ marginTop: '20px', marginRight: '10px' }}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
|
{canDeleteRole && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteRole}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid #dc3545',
|
||||||
|
backgroundColor: '#dc3545',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Role
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: '#888', textAlign: 'center', marginTop: '40px' }}>
|
<div style={{ color: '#888', textAlign: 'center', marginTop: '40px' }}>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
|
|||||||
db_user: 'harborforge',
|
db_user: 'harborforge',
|
||||||
db_password: 'harborforge_pass',
|
db_password: 'harborforge_pass',
|
||||||
db_database: 'harborforge',
|
db_database: 'harborforge',
|
||||||
backend_base_url: 'http://127.0.0.1:8000',
|
backend_base_url: 'http://backend:8000',
|
||||||
project_name: '',
|
project_name: '',
|
||||||
project_description: '',
|
project_description: '',
|
||||||
})
|
})
|
||||||
@@ -182,7 +182,7 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
|
|||||||
<h2>Backend URL</h2>
|
<h2>Backend URL</h2>
|
||||||
<p className="text-dim">Configure the HarborForge backend API URL</p>
|
<p className="text-dim">Configure the HarborForge backend API URL</p>
|
||||||
<div className="setup-form">
|
<div className="setup-form">
|
||||||
<label>Backend Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://127.0.0.1:8000" /></label>
|
<label>Backend Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://backend:8000" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="setup-nav">
|
<div className="setup-nav">
|
||||||
<button className="btn-back" onClick={() => setStep(2)}>Back</button>
|
<button className="btn-back" onClick={() => setStep(2)}>Back</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user