Compare commits
9 Commits
8eef535e02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678eb22d2b | ||
|
|
e913eb34f2 | ||
|
|
2f9f75ee3c | ||
|
|
45b78d5faf | ||
|
|
dff07ef340 | ||
|
|
625b231de5 | ||
|
|
013ddb26c7 | ||
|
|
657a224163 | ||
|
|
c957d839dd |
@@ -3,7 +3,16 @@ from fastapi import FastAPI, Depends, HTTPException, status, Query
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from model import Log, SessionLocal, Script, Settings, Subscription, Notification, User
|
||||
from model import (
|
||||
Base,
|
||||
Log,
|
||||
SessionLocal,
|
||||
Script,
|
||||
Settings,
|
||||
Subscription,
|
||||
Notification,
|
||||
User,
|
||||
)
|
||||
from run_scripts import run_scripts, update_requirements, update_environment
|
||||
import uvicorn
|
||||
from passlib.context import CryptContext
|
||||
@@ -49,6 +58,11 @@ class UserCreate(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -87,6 +101,47 @@ def hello():
|
||||
return {"message": "Welcome to the Project Monitor API"}
|
||||
|
||||
|
||||
# User Management Endpoints
|
||||
@app.get("/users", response_model=list[UserResponse])
|
||||
def list_users(current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
users = db.query(User).all()
|
||||
db.close()
|
||||
return users
|
||||
|
||||
|
||||
@app.put("/users/{user_id}")
|
||||
def update_user(
|
||||
user_id: int,
|
||||
user: UserCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
db = SessionLocal()
|
||||
existing_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not existing_user:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
existing_user.username = user.username
|
||||
existing_user.password_hash = get_password_hash(user.password)
|
||||
db.commit()
|
||||
db.refresh(existing_user)
|
||||
db.close()
|
||||
return {"message": "User updated successfully"}
|
||||
|
||||
|
||||
@app.delete("/users/{user_id}")
|
||||
def delete_user(user_id: int, current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
|
||||
@app.post("/register", response_model=Token)
|
||||
def register(user: UserCreate):
|
||||
db = SessionLocal()
|
||||
@@ -242,6 +297,41 @@ def list_subscription_notifications(
|
||||
]
|
||||
|
||||
|
||||
@app.post("/subscriptions/{subscription_id}/notifications")
|
||||
def set_all_notifications_viewed(
|
||||
subscription_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
db = SessionLocal()
|
||||
notifications = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.subscription_id == subscription_id)
|
||||
.all()
|
||||
)
|
||||
for notification in notifications:
|
||||
notification.viewed = True
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"message": "Notifications marked as viewed"}
|
||||
|
||||
|
||||
@app.delete("/subscriptions/{subscription_id}/notifications")
|
||||
def remove_subscription_notifications(
|
||||
subscription_id: int, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db = SessionLocal()
|
||||
notifications = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.subscription_id == subscription_id)
|
||||
.all()
|
||||
)
|
||||
for notification in notifications:
|
||||
db.delete(notification)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"message": "Notifications removed"}
|
||||
|
||||
|
||||
@app.get("/notifications")
|
||||
def list_notifications(current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -3,3 +3,7 @@ uvicorn
|
||||
fastapi
|
||||
sqlalchemy
|
||||
alembic
|
||||
passlib
|
||||
python-jose
|
||||
argon2_cffi
|
||||
python-multipart
|
||||
|
||||
@@ -24,7 +24,8 @@ export default defineConfig(
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
'no-undef': 'off',
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password: string | null;
|
||||
}
|
||||
|
||||
export const API_URL = env.PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
// Helper to get token from localStorage
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
const tokenData = localStorage.getItem('token');
|
||||
if (tokenData) {
|
||||
const { value, expiresAt } = JSON.parse(tokenData);
|
||||
if (Date.now() > expiresAt) {
|
||||
localStorage.removeItem('token');
|
||||
location.reload();
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to add Authorization header if token exists
|
||||
@@ -245,6 +260,28 @@ export async function addSubscription(topic: string): Promise<Subscription> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Add a new notification to a specific subscription
|
||||
export async function markAllNotificationsAsViewed(subscriptionId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to mark all notifications as viewed for subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all notifications for a specific subscription
|
||||
export async function deleteSubscriptionNotifications(subscriptionId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete notifications for subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a subscription
|
||||
export async function deleteSubscription(subscriptionId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, {
|
||||
@@ -256,6 +293,44 @@ export async function deleteSubscription(subscriptionId: number): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all users
|
||||
export async function fetchUsers(): Promise<User[]> {
|
||||
const response = await fetch(`${API_URL}/users`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Update a user
|
||||
export async function updateUser(
|
||||
userId: number,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update user');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a user
|
||||
export async function deleteUser(userId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription notifications with pagination
|
||||
export async function fetchSubscriptionNotifications(
|
||||
subscriptionId: string,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { writable } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { API_URL } from '$lib/api';
|
||||
|
||||
@@ -31,7 +30,13 @@
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem('token', data.access_token);
|
||||
const expirationTime = Date.now() + 60 * 60 * 1000; // 60 minutes in milliseconds
|
||||
localStorage.setItem(
|
||||
'token',
|
||||
JSON.stringify({ value: data.access_token, expiresAt: expirationTime })
|
||||
);
|
||||
localStorage.setItem('username', loginUsername);
|
||||
|
||||
goto('/').then(() => location.reload());
|
||||
} catch (err) {
|
||||
loginError = 'Network error - ' + err;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { deleteNotification, setViewed, fetchSubscriptionNotifications } from '$lib/api';
|
||||
import {
|
||||
deleteNotification,
|
||||
setViewed,
|
||||
fetchSubscriptionNotifications,
|
||||
deleteSubscriptionNotifications,
|
||||
markAllNotificationsAsViewed
|
||||
} from '$lib/api';
|
||||
import type { Notification, Subscription } from '$lib/api';
|
||||
let { data } = $props();
|
||||
|
||||
@@ -15,7 +21,7 @@
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await Promise.all(notifications.map((notification) => deleteNotification(notification.id)));
|
||||
deleteSubscriptionNotifications(subscription!.id);
|
||||
notifications = [];
|
||||
window.showNotification('success', 'All notifications deleted successfully.');
|
||||
} catch (error) {
|
||||
@@ -58,11 +64,7 @@
|
||||
}
|
||||
async function markAllViewed() {
|
||||
try {
|
||||
await Promise.all(
|
||||
notifications
|
||||
.filter((notification) => !notification.viewed)
|
||||
.map((notification) => setViewed(notification.id))
|
||||
);
|
||||
markAllNotificationsAsViewed(subscription!.id);
|
||||
notifications = notifications.map((notification) =>
|
||||
notification.viewed ? notification : { ...notification, viewed: true }
|
||||
);
|
||||
@@ -76,21 +78,21 @@
|
||||
async function loadMoreNotifications() {
|
||||
loadingMore = true;
|
||||
try {
|
||||
const more = await fetchSubscriptionNotifications(subscription.id.toString(), limit, offset);
|
||||
const more = await fetchSubscriptionNotifications(subscription!.id.toString(), limit, offset);
|
||||
notifications = [...notifications, ...more];
|
||||
offset += more.length;
|
||||
if (more.length < limit) {
|
||||
allLoaded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
window.showNotification('error', 'Failed to load more notifications');
|
||||
} catch (err) {
|
||||
window.showNotification('error', 'Failed to load more notifications - ' + err);
|
||||
}
|
||||
loadingMore = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Notifications for {subscription.topic}:</h1>
|
||||
<h1 class="text-2xl font-bold mb-4">Notifications for {subscription!.topic}:</h1>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<a href="/notifications" class="text-blue-500 hover:underline">← Return to Subscriptions</a>
|
||||
@@ -120,6 +122,11 @@
|
||||
<button class="p-2 w-full text-left" onclick={() => openNotificationPopup(notification)}>
|
||||
<div>
|
||||
<p class="font-semibold">{notification.title}</p>
|
||||
<p>
|
||||
{notification.message.split('\n')[0].length > 75
|
||||
? `${notification.message.split('\n')[0].slice(0, 75)}...`
|
||||
: notification.message.split('\n')[0]}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{new Date(notification.created_at).toLocaleString()}
|
||||
</p>
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
}
|
||||
const data = await response.json();
|
||||
localStorage.setItem('token', data.access_token);
|
||||
goto('/');
|
||||
goto('/').then(() => location.reload());
|
||||
} catch (err) {
|
||||
error = 'Network error';
|
||||
error = 'Network error - ' + err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let params: { id: string };
|
||||
let script: Script = null;
|
||||
let script: Script | null = null;
|
||||
let logs: Log[] = [];
|
||||
let updatedTitle: string = '';
|
||||
let updatedContent: string = '';
|
||||
@@ -57,10 +57,10 @@
|
||||
|
||||
async function handleExecuteScript() {
|
||||
try {
|
||||
await executeScript(script.id);
|
||||
await executeScript(script!.id);
|
||||
window.showNotification('success', 'Script executed successfully!');
|
||||
// Reload the list of logs after execution
|
||||
logs = (await fetchLogs(script.id)).sort(
|
||||
logs = (await fetchLogs(script!.id)).sort(
|
||||
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
async function handleDeleteLog(logId: number) {
|
||||
try {
|
||||
await deleteLog(script.id, logId);
|
||||
await deleteLog(script!.id, logId);
|
||||
logs = logs.filter((log) => log.id !== logId);
|
||||
window.showNotification('success', 'Log deleted successfully!');
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchUserSettings, updateSetting } from '$lib/api';
|
||||
import type { Settings } from '$lib/api';
|
||||
import { fetchUserSettings, updateSetting, fetchUsers, updateUser, deleteUser } from '$lib/api';
|
||||
import type { Settings, User } from '$lib/api';
|
||||
import CodeMirror from 'svelte-codemirror-editor';
|
||||
|
||||
let settings: Settings = $state(null);
|
||||
let settings: Settings | null = $state(null);
|
||||
let users: User[] = $state([]);
|
||||
let currentUser: string | null = $state(localStorage.getItem('username'));
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
@@ -21,6 +23,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
users = await fetchUsers();
|
||||
} catch (err) {
|
||||
error = 'Failed to load users - ' + err;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSetting(setting: Settings) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
@@ -34,8 +48,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateUser(user: User, username: string, password: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
await updateUser(user.id, username, password);
|
||||
loadUsers(); // Refresh users after update
|
||||
} catch (err) {
|
||||
error = 'Failed to update user - ' + err;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser(userId: number) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
await deleteUser(userId);
|
||||
loadUsers(); // Refresh users after deletion
|
||||
} catch (err) {
|
||||
error = 'Failed to delete user - ' + err;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSettings();
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -51,14 +92,14 @@
|
||||
<div class="p-4 border rounded shadow">
|
||||
<label class="block mb-2 font-bold">
|
||||
Requirements
|
||||
<div class="w-full border rounded">
|
||||
<div class="w-full border rounded font-normal">
|
||||
<CodeMirror bind:value={settings.requirements} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 mb-2 font-bold">
|
||||
Environment
|
||||
<div class="w-full border rounded">
|
||||
<div class="w-full border rounded font-normal">
|
||||
<CodeMirror bind:value={settings.environment} />
|
||||
</div>
|
||||
</label>
|
||||
@@ -74,12 +115,71 @@
|
||||
|
||||
<button
|
||||
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
onclick={() => saveSetting(settings)}
|
||||
onclick={() => saveSetting(settings!)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-8">User Management</h2>
|
||||
{#if isLoading}
|
||||
<p>Loading users...</p>
|
||||
{:else if error}
|
||||
<p class="text-red-500">{$error}</p>
|
||||
{:else}
|
||||
<table
|
||||
class="table-auto w-full mt-4 border-collapse border border-gray-300 shadow-lg rounded-lg"
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-gray-100">
|
||||
<th class="px-4 py-2 border border-gray-300 text-left font-semibold">Users</th>
|
||||
<th class="px-4 py-2 border border-gray-300 text-center font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="border px-4 py-2 border-gray-300">
|
||||
{#if user.username === currentUser}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New Username"
|
||||
class="px-2 py-1 border rounded"
|
||||
bind:value={user.username}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
class="px-2 py-1 border rounded"
|
||||
bind:value={user.password}
|
||||
/>
|
||||
{:else}
|
||||
{user.username}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="border px-4 py-2 border-gray-300 text-center">
|
||||
{#if user.username === currentUser}
|
||||
<button
|
||||
class="ml-2 px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600"
|
||||
onclick={() => handleUpdateUser(user, user.username, user.password)}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="ml-2 px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
onclick={() => handleDeleteUser(user.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user