Merge remote-tracking branch 'origin/dev-1.5.0' into fork/ZacharyZcR/main

# Conflicts:
#	src/ui/Admin/AdminSettings.tsx
This commit is contained in:
LukeGus
2025-09-02 20:57:44 -05:00
6 changed files with 54 additions and 62 deletions

2
.env
View File

@@ -1 +1 @@
VERSION=1.4.0 VERSION=1.5.0

View File

@@ -16,7 +16,6 @@ import {
TableRow, TableRow,
} from "@/components/ui/table.tsx"; } from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react"; import {Shield, Trash2, Users} from "lucide-react";
import {toast} from "sonner";
import { import {
getOIDCConfig, getOIDCConfig,
getRegistrationAllowed, getRegistrationAllowed,
@@ -53,11 +52,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
token_url: '', token_url: '',
identifier_path: 'sub', identifier_path: 'sub',
name_path: 'name', name_path: 'name',
scopes: 'openid email profile', scopes: 'openid email profile'
userinfo_url: ''
}); });
const [oidcLoading, setOidcLoading] = React.useState(false); const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null); const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
const [users, setUsers] = React.useState<Array<{ const [users, setUsers] = React.useState<Array<{
id: string; id: string;
@@ -69,6 +68,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const [newAdminUsername, setNewAdminUsername] = React.useState(""); const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null); const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
@@ -120,6 +120,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
e.preventDefault(); e.preventDefault();
setOidcLoading(true); setOidcLoading(true);
setOidcError(null); setOidcError(null);
setOidcSuccess(null);
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url']; const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]); const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
@@ -132,7 +133,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await updateOIDCConfig(oidcConfig); await updateOIDCConfig(oidcConfig);
toast.success("OIDC configuration updated successfully!"); setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) { } catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration"); setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally { } finally {
@@ -144,15 +145,16 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
setOidcConfig(prev => ({...prev, [field]: value})); setOidcConfig(prev => ({...prev, [field]: value}));
}; };
const handleMakeUserAdmin = async (e: React.FormEvent) => { const makeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newAdminUsername.trim()) return; if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true); setMakeAdminLoading(true);
setMakeAdminError(null); setMakeAdminError(null);
setMakeAdminSuccess(null);
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await makeUserAdmin(newAdminUsername.trim()); await makeUserAdmin(newAdminUsername.trim());
toast.success(`User ${newAdminUsername} is now an admin`); setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername(""); setNewAdminUsername("");
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
@@ -162,29 +164,23 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
} }
}; };
const handleRemoveAdminStatus = async (username: string) => { const removeAdminStatus = async (username: string) => {
if (!confirm(`Remove admin status from ${username}?`)) return; if (!confirm(`Remove admin status from ${username}?`)) return;
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await removeAdminStatus(username); await removeAdminStatus(username);
toast.success(`Admin status removed from ${username}`);
fetchUsers(); fetchUsers();
} catch (err: any) { } catch {
console.error('Failed to remove admin status:', err);
toast.error('Failed to remove admin status');
} }
}; };
const handleDeleteUser = async (username: string) => { const deleteUser = async (username: string) => {
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return; if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await deleteUser(username); await deleteUser(username);
toast.success(`User ${username} deleted successfully`);
fetchUsers(); fetchUsers();
} catch (err: any) { } catch {
console.error('Failed to delete user:', err);
toast.error('Failed to delete user');
} }
}; };
@@ -300,15 +296,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label> <Label htmlFor="scopes">Scopes</Label>
<Input id="scopes" value={oidcConfig.scopes} <Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)} onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile" required/> placeholder="openid email profile" required/>
</div> </div>
<div className="space-y-2">
<Label htmlFor="userinfo_url">Overide User Info URL (not required)</Label>
<Input id="userinfo_url" value={oidcConfig.userinfo_url}
onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)}
placeholder="https://your-provider.com/application/o/userinfo/"/>
</div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1" <Button type="submit" className="flex-1"
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button> disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
@@ -320,10 +310,16 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
token_url: '', token_url: '',
identifier_path: 'sub', identifier_path: 'sub',
name_path: 'name', name_path: 'name',
scopes: 'openid email profile', scopes: 'openid email profile'
userinfo_url: ''
})}>Reset</Button> })}>Reset</Button>
</div> </div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form> </form>
</div> </div>
</TabsContent> </TabsContent>
@@ -361,7 +357,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell> className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" <Button variant="ghost" size="sm"
onClick={() => handleDeleteUser(user.username)} onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50" className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}> disabled={user.is_admin}>
<Trash2 className="h-4 w-4"/> <Trash2 className="h-4 w-4"/>
@@ -381,7 +377,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<h3 className="text-lg font-semibold">Admin Management</h3> <h3 className="text-lg font-semibold">Admin Management</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50"> <div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4> <h4 className="font-medium">Make User Admin</h4>
<form onSubmit={handleMakeUserAdmin} className="space-y-4"> <form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label> <Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -398,7 +394,12 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<AlertDescription>{makeAdminError}</AlertDescription> <AlertDescription>{makeAdminError}</AlertDescription>
</Alert> </Alert>
)} )}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form> </form>
</div> </div>
@@ -425,7 +426,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell> className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" <Button variant="ghost" size="sm"
onClick={() => handleRemoveAdminStatus(admin.username)} onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"> className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/> <Shield className="h-4 w-4"/>
Remove Admin Remove Admin

View File

@@ -19,6 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
import React, {useEffect, useRef, useState} from "react"; import React, {useEffect, useRef, useState} from "react";
import {Switch} from "@/components/ui/switch.tsx"; import {Switch} from "@/components/ui/switch.tsx";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx"; import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
import {toast} from "sonner";
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts'; import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
interface SSHHost { interface SSHHost {
@@ -244,8 +245,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
if (editingHost) { if (editingHost) {
await updateSSHHost(editingHost.id, formData); await updateSSHHost(editingHost.id, formData);
toast.success(`Host "${formData.name}" updated successfully!`);
} else { } else {
await createSSHHost(formData); await createSSHHost(formData);
toast.success(`Host "${formData.name}" added successfully!`);
} }
if (onFormSubmit) { if (onFormSubmit) {
@@ -254,7 +257,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) { } catch (error) {
alert('Failed to save host. Please try again.'); toast.error('Failed to save host. Please try again.');
} }
}; };

View File

@@ -7,6 +7,7 @@ import {Input} from "@/components/ui/input";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts"; import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
import {toast} from "sonner";
import { import {
Edit, Edit,
Trash2, Trash2,
@@ -74,10 +75,11 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) { if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
try { try {
await deleteSSHHost(hostId); await deleteSSHHost(hostId);
toast.success(`Host "${hostName}" deleted successfully!`);
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) { } catch (err) {
alert('Failed to delete host'); toast.error('Failed to delete host');
} }
} }
}; };
@@ -114,16 +116,19 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const result = await bulkImportSSHHosts(hostsArray); const result = await bulkImportSSHHosts(hostsArray);
if (result.success > 0) { if (result.success > 0) {
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`); toast.success(`Import completed: ${result.success} hosts imported successfully${result.failed > 0 ? `, ${result.failed} failed` : ''}`);
if (result.errors.length > 0) {
toast.error(`Import errors: ${result.errors.join(', ')}`);
}
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} else { } else {
alert(`Import failed: ${result.errors.join('\n')}`); toast.error(`Import failed: ${result.errors.join(', ')}`);
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file'; const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file';
alert(`Import error: ${errorMessage}`); toast.error(`Import error: ${errorMessage}`);
} finally { } finally {
setImporting(false); setImporting(false);
event.target.value = ''; event.target.value = '';

View File

@@ -355,7 +355,7 @@ export function LeftSidebar({
} }
}; };
const makeUserAdmin = async (e: React.FormEvent) => { const handleMakeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newAdminUsername.trim()) return; if (!newAdminUsername.trim()) return;
@@ -380,7 +380,7 @@ export function LeftSidebar({
} }
}; };
const removeAdminStatus = async (username: string) => { const handleRemoveAdminStatus = async (username: string) => {
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return; if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
if (!isAdmin) { if (!isAdmin) {
@@ -392,10 +392,11 @@ export function LeftSidebar({
await removeAdminStatus(username); await removeAdminStatus(username);
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
console.error('Failed to remove admin status:', err);
} }
}; };
const deleteUser = async (username: string) => { const handleDeleteUser = async (username: string) => {
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return; if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
if (!isAdmin) { if (!isAdmin) {
@@ -407,6 +408,7 @@ export function LeftSidebar({
await deleteUser(username); await deleteUser(username);
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
console.error('Failed to delete user:', err);
} }
}; };

View File

@@ -6,6 +6,7 @@ import {Label} from "@/components/ui/label.tsx";
import {Input} from "@/components/ui/input.tsx"; import {Input} from "@/components/ui/input.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {toast} from "sonner";
interface PasswordResetProps { interface PasswordResetProps {
userInfo: { userInfo: {
@@ -25,7 +26,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [tempToken, setTempToken] = useState(""); const [tempToken, setTempToken] = useState("");
const [resetLoading, setResetLoading] = useState(false); const [resetLoading, setResetLoading] = useState(false);
const [resetSuccess, setResetSuccess] = useState(false);
async function handleInitiatePasswordReset() { async function handleInitiatePasswordReset() {
setError(null); setError(null);
@@ -48,7 +48,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setConfirmPassword(""); setConfirmPassword("");
setTempToken(""); setTempToken("");
setError(null); setError(null);
setResetSuccess(false);
} }
async function handleVerifyResetCode() { async function handleVerifyResetCode() {
@@ -85,14 +84,8 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
try { try {
await completePasswordReset(userInfo.username, tempToken, newPassword); await completePasswordReset(userInfo.username, tempToken, newPassword);
setResetStep("initiate"); toast.success("Password reset successfully! You can now log in with your new password.");
setResetCode(""); resetPasswordState();
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
setResetSuccess(true);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || "Failed to complete password reset"); setError(err?.response?.data?.error || "Failed to complete password reset");
} finally { } finally {
@@ -120,7 +113,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<> <>
{resetStep === "initiate" && !resetSuccess && ( {resetStep === "initiate" && (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button <Button
@@ -180,19 +173,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
</> </>
)} )}
{resetSuccess && ( {resetStep === "newPassword" && (
<>
<Alert className="">
<AlertTitle>Success!</AlertTitle>
<AlertDescription>
Your password has been successfully reset! You can now log in
with your new password.
</AlertDescription>
</Alert>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<> <>
<div className="text-center text-muted-foreground mb-4"> <div className="text-center text-muted-foreground mb-4">
<p>Enter your new password for <p>Enter your new password for