Added admin page and more authentication improvements with latest handy-httpd version.

This commit is contained in:
Andrew Lalis 2023-08-24 15:37:25 -04:00
parent 859f17ccc5
commit f3fe5e9671
20 changed files with 297 additions and 107 deletions

View File

@ -5,17 +5,18 @@
"copyright": "Copyright © 2023, Andrew Lalis",
"dependencies": {
"botan": "~>1.13.5",
"d-properties": "~>1.0.4",
"d-properties": "~>1.0.5",
"d2sqlite3": "~>1.0.0",
"handy-httpd": "~>7.9.3",
"handy-httpd": "~>7.10.4",
"jwt": "~>0.4.0",
"resusage": "~>0.3.2",
"slf4d": "~>2.4.2"
"slf4d": "~>2.4.3"
},
"description": "API for the litelist application.",
"license": "MIT",
"name": "litelist-api",
"subConfigurations": {
"d2sqlite3": "all-included"
}
},
"buildRequirements": ["allowWarnings"]
}

View File

@ -3,14 +3,14 @@
"versions": {
"botan": "1.13.5",
"botan-math": "1.0.4",
"d-properties": "1.0.4",
"d-properties": "1.0.5",
"d2sqlite3": "1.0.0",
"handy-httpd": "7.9.3",
"handy-httpd": "7.10.4",
"httparsed": "1.2.1",
"jwt": "0.4.0",
"memutils": "1.0.9",
"resusage": "0.3.2",
"slf4d": "2.4.2",
"slf4d": "2.4.3",
"streams": "3.5.0"
}
}

View File

@ -4,7 +4,7 @@ import slf4d.default_provider;
void main() {
auto provider = new shared DefaultProvider(true, Levels.INFO);
// provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN);
// provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.DEBUG);
configureLoggingProvider(provider);
HttpServer server = initServer();
@ -21,9 +21,12 @@ private HttpServer initServer() {
import d_properties;
import endpoints.auth;
import endpoints.lists;
import endpoints.admin;
import std.file;
import std.conv;
import auth : TokenFilter, AdminFilter, loadTokenSecret;
ServerConfig config = ServerConfig.defaultValues();
config.enableWebSockets = false;
config.workerPoolSize = 3;
@ -56,28 +59,42 @@ private HttpServer initServer() {
immutable string API_PATH = "/api";
auto mainHandler = new PathDelegatingHandler();
PathDelegatingHandler mainHandler = new PathDelegatingHandler();
mainHandler.addMapping(Method.GET, API_PATH ~ "/status", &handleStatus);
auto optionsHandler = toHandler((ref HttpRequestContext ctx) {
ctx.response.setStatus(HttpStatus.OK);
});
mainHandler.addMapping(Method.POST, API_PATH ~ "/register", &createNewUser);
mainHandler.addMapping(Method.POST, API_PATH ~ "/login", &handleLogin);
mainHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
mainHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
mainHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken);
mainHandler.addMapping(Method.GET, API_PATH ~ "/lists", &getNoteLists);
mainHandler.addMapping(Method.POST, API_PATH ~ "/lists", &createNoteList);
mainHandler.addMapping(Method.GET, API_PATH ~ "/lists/{id}", &getNoteList);
mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{id}", &deleteNoteList);
mainHandler.addMapping(Method.POST, API_PATH ~ "/lists/{listId}/notes", &createNote);
mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{listId}/notes/{noteId}", &deleteNote);
// mainHandler.addMapping(Method.GET, API_PATH ~ "/shutdown", (ref HttpRequestContext ctx) {
// ctx.response.writeBodyString("Shutting down!");
// ctx.server.stop();
// });
HttpRequestHandler optionsHandler = toHandler((ref HttpRequestContext ctx) {
ctx.response.setStatus(HttpStatus.OK);
});
mainHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", optionsHandler);
// Separate handler for authenticated paths, protected by a TokenFilter.
PathDelegatingHandler authHandler = new PathDelegatingHandler();
authHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
authHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
authHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken);
authHandler.addMapping(Method.GET, API_PATH ~ "/lists", &getNoteLists);
authHandler.addMapping(Method.POST, API_PATH ~ "/lists", &createNoteList);
authHandler.addMapping(Method.GET, API_PATH ~ "/lists/{id}", &getNoteList);
authHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{id}", &deleteNoteList);
authHandler.addMapping(Method.POST, API_PATH ~ "/lists/{listId}/notes", &createNote);
authHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{listId}/notes/{noteId}", &deleteNote);
HttpRequestFilter tokenFilter = new TokenFilter(loadTokenSecret());
HttpRequestFilter adminFilter = new AdminFilter();
// Separate handler for admin paths, protected by an AdminFilter.
PathDelegatingHandler adminHandler = new PathDelegatingHandler();
adminHandler.addMapping(Method.GET, API_PATH ~ "/admin/users", &getAllUsers);
mainHandler.addMapping(API_PATH ~ "/admin/**", new FilteredRequestHandler(adminHandler, [tokenFilter, adminFilter]));
mainHandler.addMapping(API_PATH ~ "/**", new FilteredRequestHandler(authHandler, [tokenFilter]));
return new HttpServer(mainHandler, config);
}

View File

@ -7,8 +7,12 @@ import handy_httpd;
import handy_httpd.handlers.filtered_handler;
import slf4d;
import std.typecons;
import data.user;
immutable string AUTH_METADATA_KEY = "AuthContext";
/**
* Generates a new access token for an authenticated user.
* Params:
@ -25,7 +29,7 @@ string generateToken(in User user, in string secret) {
token.claims.sub(user.username);
token.claims.exp(Clock.currTime.toUnixTime() + 5000);
token.claims.iss("litelist-api");
return token.encode("supersecret");// TODO: Extract secret.
return token.encode(secret);
}
void sendUnauthenticatedResponse(ref HttpResponse resp) {
@ -46,39 +50,14 @@ string loadTokenSecret() {
return "supersecret";
}
struct AuthContext {
class AuthContext {
string token;
User user;
}
class AuthContextHolder {
private static AuthContextHolder instance;
static getInstance() {
if (!instance) instance = new AuthContextHolder();
return instance;
this(string token, User user) {
this.token = token;
this.user = user;
}
static reset() {
auto i = getInstance();
i.authenticated = false;
i.context = AuthContext.init;
}
static setContext(string token, User user) {
auto i = getInstance();
i.authenticated = true;
i.context = AuthContext(token, user);
}
static AuthContext getOrThrow() {
auto i = getInstance();
if (!i.authenticated) throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "No authentication context.");
return i.context;
}
private bool authenticated;
private AuthContext context;
}
/**
@ -88,46 +67,46 @@ class AuthContextHolder {
* Params:
* ctx = The request context to validate.
* secret = The secret key that should have been used to sign the token.
* Returns: True if the user is authenticated, or false otherwise.
* Returns: The AuthContext if authentication is successful, or null otherwise.
*/
bool validateAuthenticatedRequest(ref HttpRequestContext ctx, in string secret) {
Nullable!AuthContext validateAuthenticatedRequest(ref HttpRequestContext ctx, in string secret) {
import jwt.jwt : verify, Token;
import jwt.algorithms : JWTAlgorithm;
import std.typecons;
immutable HEADER_NAME = "Authorization";
AuthContextHolder.reset();
if (!ctx.request.hasHeader(HEADER_NAME)) {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("Missing Authorization header.");
return false;
return Nullable!AuthContext.init;
}
string authHeader = ctx.request.getHeader(HEADER_NAME);
if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("Invalid bearer token authorization header.");
return false;
return Nullable!AuthContext.init;
}
string rawToken = authHeader[7 .. $];
string username;
try {
Token token = verify(rawToken, "supersecret", [JWTAlgorithm.HS512]);
Token token = verify(rawToken, secret, [JWTAlgorithm.HS512]);
username = token.claims.sub;
} catch (Exception e) {
warn("Failed to verify user token.", e);
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid token.");
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("Invalid or malformed token.");
return Nullable!AuthContext.init;
}
Nullable!User user = userDataSource.getUser(username);
if (user.isNull) {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("User does not exist.");
return false;
return Nullable!AuthContext.init;
}
AuthContextHolder.setContext(rawToken, user.get);
return true;
return nullable(new AuthContext(rawToken, user.get));
}
class TokenFilter : HttpRequestFilter {
@ -138,6 +117,30 @@ class TokenFilter : HttpRequestFilter {
}
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
if (validateAuthenticatedRequest(ctx, this.secret)) filterChain.doFilter(ctx);
Nullable!AuthContext optionalAuth = validateAuthenticatedRequest(ctx, this.secret);
if (!optionalAuth.isNull) {
ctx.metadata[AUTH_METADATA_KEY] = optionalAuth.get();
filterChain.doFilter(ctx); // Only continue the filter chain if we're authenticated.
}
}
}
class AdminFilter : HttpRequestFilter {
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
AuthContext authCtx = getAuthContextOrThrow(ctx);
if (authCtx.user.admin) {
filterChain.doFilter(ctx);
} else {
ctx.response.setStatus(HttpStatus.FORBIDDEN);
}
}
}
AuthContext getAuthContextOrThrow(ref HttpRequestContext ctx) {
if (AUTH_METADATA_KEY in ctx.metadata) {
if (auto authCtx = cast(AuthContext) ctx.metadata[AUTH_METADATA_KEY]) {
return authCtx;
}
}
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated.");
}

View File

@ -102,4 +102,9 @@ class SqliteNoteListDataSource : NoteListDataSource {
db.commit();
return NoteList(id, newData.name, newData.ordinality, newData.description, []);
}
ulong countLists(string username) {
Database db = getDb(username);
return db.execute("SELECT COUNT(id) FROM note_list").oneValue!ulong();
}
}

View File

@ -41,16 +41,18 @@ class SqliteNoteDataSource : NoteDataSource {
if (newData.ordinality > note.ordinality) {
// Decrement all notes between the old index and the new one.
db.execute(
"UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ?",
"UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ? AND note_list_id = ?",
note.ordinality,
newData.ordinality
newData.ordinality,
note.noteListId
);
} else {
// Increment all notes between the old index and the new one.
db.execute(
"UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?",
"UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ? AND note_list_id = ?",
newData.ordinality,
note.ordinality
note.ordinality,
note.noteListId
);
}
}
@ -66,14 +68,27 @@ class SqliteNoteDataSource : NoteDataSource {
void deleteNote(string username, ulong id) {
Database db = getDb(username);
ResultRange result = db.execute("SELECT * FROM note WHERE id = ?", id);
if (result.empty) return;
Note note = parseNote(result.front);
db.begin();
Nullable!uint ordinality = db.execute(
"SELECT ordinality FROM note WHERE id = ?", id
).oneValue!(Nullable!uint)();
db.execute("DELETE FROM note WHERE id = ?", id);
if (!ordinality.isNull) {
db.execute("UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ?", ordinality.get);
}
db.execute(
"UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND note_list_id = ?",
note.ordinality,
note.noteListId
);
db.commit();
}
ulong countNotes(string username) {
return getDb(username)
.execute("SELECT COUNT(id) FROM note")
.oneValue!ulong();
}
ulong countNotes(string username, ulong noteListId) {
return getDb(username)
.execute("SELECT COUNT(id) FROM note WHERE note_list_id = ?", noteListId)
.oneValue!ulong();
}
}

View File

@ -18,9 +18,9 @@ class FileSystemUserDataSource : UserDataSource {
mkdirRecurse(dirPath);
string dataPath = buildPath(dirPath, DATA_FILE);
JSONValue userObj = JSONValue(string[string].init);
userObj.object["username"] = username;
userObj.object["email"] = email;
userObj.object["passwordHash"] = passwordHash;
userObj.object["email"] = JSONValue(email);
userObj.object["passwordHash"] = JSONValue(passwordHash);
userObj.object["admin"] = JSONValue(false);
std.file.write(dataPath, userObj.toPrettyString());
return User(username, email, passwordHash);
}
@ -35,11 +35,16 @@ class FileSystemUserDataSource : UserDataSource {
string dataPath = buildPath(USERS_DIR, username, DATA_FILE);
if (exists(dataPath) && isFile(dataPath)) {
JSONValue userObj = parseJSON(strip(readText(dataPath)));
return nullable(User(
userObj.object["username"].str,
userObj.object["email"].str,
userObj.object["passwordHash"].str
));
string email = userObj.object["email"].str;
string passwordHash = userObj.object["passwordHash"].str;
bool admin = false;
if ("admin" !in userObj.object) {
userObj.object["admin"] = JSONValue(false);
std.file.write(dataPath, userObj.toPrettyString());
} else {
admin = userObj.object["admin"].boolean;
}
return nullable(User(username, email, passwordHash, admin));
}
return Nullable!User.init;
}

View File

@ -10,6 +10,7 @@ interface NoteListDataSource {
NoteList createNoteList(string username, string name, string description = null);
void deleteNoteList(string username, ulong id);
NoteList updateNoteList(string username, ulong id, NoteList newData);
ulong countLists(string username);
}
static NoteListDataSource noteListDataSource;

View File

@ -4,6 +4,7 @@ struct User {
string username;
string email;
string passwordHash;
bool admin;
}
struct NoteList {

View File

@ -6,6 +6,8 @@ interface NoteDataSource {
Note createNote(string username, ulong noteListId, string content);
Note updateNote(string username, ulong id, Note newData);
void deleteNote(string username, ulong id);
ulong countNotes(string username);
ulong countNotes(string username, ulong noteListId);
}
static NoteDataSource noteDataSource;

View File

@ -0,0 +1,33 @@
module endpoints.admin;
import handy_httpd;
import slf4d;
import std.file;
import std.path;
import std.json;
void getAllUsers(ref HttpRequestContext ctx) {
import data.impl.user;
import data.list;
import data.note;
JSONValue usersArray = JSONValue(string[].init);
foreach (DirEntry entry; dirEntries(USERS_DIR, SpanMode.shallow, false)) {
string username = baseName(entry.name);
JSONValue userData = parseJSON(readText(buildPath(USERS_DIR, username, DATA_FILE)));
string email = userData.object["email"].str;
bool admin = userData.object["admin"].boolean;
ulong listCount = noteListDataSource.countLists(username);
ulong noteCount = noteDataSource.countNotes(username);
JSONValue userObj = JSONValue(string[string].init);
userObj.object["username"] = JSONValue(username);
userObj.object["email"] = JSONValue(email);
userObj.object["admin"] = JSONValue(admin);
userObj.object["listCount"] = JSONValue(listCount);
userObj.object["noteCount"] = JSONValue(noteCount);
usersArray.array ~= userObj;
}
ctx.response.writeBodyString(usersArray.toString(), "application/json");
}

View File

@ -42,8 +42,7 @@ void handleLogin(ref HttpRequestContext ctx) {
}
void renewToken(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
JSONValue resp = JSONValue(string[string].init);
resp.object["token"] = generateToken(auth.user, loadTokenSecret());
@ -51,11 +50,33 @@ void renewToken(ref HttpRequestContext ctx) {
}
void createNewUser(ref HttpRequestContext ctx) {
import std.regex;
JSONValue userData = ctx.request.readBodyAsJson();
if ("username" !in userData.object || "email" !in userData.object || "password" !in userData.object) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Missing required data.");
return;
}
string username = userData.object["username"].str;
string email = userData.object["email"].str;
string password = userData.object["password"].str;
const ctr = ctRegex!(`^[a-zA-Z0-9][a-zA-Z0-9-_]{2,23}$`);
Captures!string c = matchFirst(username, ctr);
if (c.empty) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Invalid username.");
return;
}
if (password.length < 8) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Password is too short. Should be at least 8 characters.");
return;
}
if (!userDataSource.getUser(username).isNull) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Username is taken.");
@ -72,16 +93,15 @@ void createNewUser(ref HttpRequestContext ctx) {
}
void getMyUser(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
JSONValue resp = JSONValue(string[string].init);
resp.object["username"] = JSONValue(auth.user.username);
resp.object["email"] = JSONValue(auth.user.email);
resp.object["admin"] = JSONValue(auth.user.admin);
ctx.response.writeBodyString(resp.toString(), "application/json");
}
void deleteMyUser(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
userDataSource.deleteUser(auth.user.username);
}

View File

@ -11,8 +11,7 @@ import data.list;
import data.note;
void getNoteLists(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
NoteList[] lists = noteListDataSource.getLists(auth.user.username);
JSONValue listsArray = JSONValue(string[].init);
foreach (NoteList list; lists) {
@ -22,8 +21,7 @@ void getNoteLists(ref HttpRequestContext ctx) {
}
void getNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
ulong id = ctx.request.getPathParamAs!ulong("id");
Nullable!NoteList optionalList = noteListDataSource.getList(auth.user.username, id);
if (!optionalList.isNull) {
@ -34,8 +32,7 @@ void getNoteList(ref HttpRequestContext ctx) {
}
void createNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
JSONValue requestBody = ctx.request.readBodyAsJson();
if ("name" !in requestBody.object) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
@ -56,8 +53,7 @@ void createNoteList(ref HttpRequestContext ctx) {
}
void createNote(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
ulong listId = ctx.request.getPathParamAs!ulong("listId");
JSONValue requestBody = ctx.request.readBodyAsJson();
if (
@ -75,14 +71,12 @@ void createNote(ref HttpRequestContext ctx) {
}
void deleteNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
noteListDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id"));
}
void deleteNote(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow();
AuthContext auth = getAuthContextOrThrow(ctx);
ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
noteDataSource.deleteNote(auth.user.username, noteId);
}

View File

@ -0,0 +1,19 @@
import {API_URL} from "@/api/base";
export interface AdminUserInfo {
username: string
email: string
admin: boolean
listCount: number
noteCount: number
}
export async function getAllUsers(token: string): Promise<AdminUserInfo[]> {
const response = await fetch(API_URL + "/admin/users", {
headers: {"Authorization": "Bearer " + token}
})
if (response.ok) {
return await response.json()
}
throw response
}

View File

@ -3,10 +3,11 @@ import {API_URL} from "@/api/base";
export interface User {
username: string
email: string
admin: boolean
}
export function emptyUser(): User {
return {username: "", email: ""}
return {username: "", email: "", admin: false}
}
export interface LoginInfo {

View File

@ -4,26 +4,35 @@ a mobile-friendly width.
-->
<script setup lang="ts">
import type {Ref} from "vue";
import {onMounted, ref} from "vue";
import {onMounted, onUnmounted, ref} from "vue";
import type {StatusInfo} from "@/api/base";
import {getStatus} from "@/api/base";
import {humanFileSize} from "@/util";
import {useAuthStore} from "@/stores/auth";
const statusInfo: Ref<StatusInfo | null> = ref(null)
const statusRefreshInterval: Ref<number | null> = ref(null);
const authStore = useAuthStore()
onMounted(async () => {
statusInfo.value = await getStatus()
setInterval(async () => {
statusRefreshInterval.value = setInterval(async () => {
statusInfo.value = await getStatus()
}, 5000)
})
onUnmounted(() => {
if (statusRefreshInterval.value) {
clearInterval(statusRefreshInterval.value)
}
})
</script>
<template>
<div class="page-container">
<slot/>
<!-- Each contained page also gets a nice little footer! -->
<footer style="text-align: center">
<footer style="text-align: center; margin-top: 2rem;">
<p style="font-size: smaller">
LiteList created with by
<a href="https://andrewlalis.com" target="_blank">Andrew Lalis</a>
@ -33,6 +42,9 @@ onMounted(async () => {
<p v-if="statusInfo" style="font-size: smaller; font-family: monospace;">
Memory used: <span v-text="humanFileSize(statusInfo.physicalMemory, true, 1)"></span>
</p>
<p v-if="authStore.authenticated && authStore.user.admin" style="font-size: smaller">
<RouterLink to="/admin">Admin Page</RouterLink>
</p>
</footer>
</div>
</template>

View File

@ -3,6 +3,7 @@ import LoginView from "@/views/LoginView.vue";
import ListsView from "@/views/ListsView.vue";
import {useAuthStore} from "@/stores/auth";
import SingleListView from "@/views/SingleListView.vue";
import AdminView from "@/views/AdminView.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -31,6 +32,10 @@ const router = createRouter({
{
path: "/lists/:id",
component: SingleListView
},
{
path: "/admin",
component: AdminView
}
]
})
@ -40,12 +45,19 @@ const publicRoutes = [
"/login"
]
const adminRoutes = [
"/admin"
]
router.beforeEach(async (to, from) => {
const authStore = useAuthStore()
await authStore.tryLogInFromStoredToken()
if (!publicRoutes.includes(to.path) && !authStore.authenticated) {
return "/login" // Redirect to login page if user is trying to go to an authenticated page.
}
if (adminRoutes.includes(to.path) && !authStore.user.admin) {
return "/lists" // Redirect to /lists if a non-admin user tries to access an admin page.
}
})
export default router

View File

@ -61,7 +61,7 @@ export const useAuthStore = defineStore("auth", () => {
try {
const storedUser = await getMyUser(storedToken)
console.log("Logging in using stored token for user: " + storedUser.username)
logIn(storedToken, storedUser)
await logIn(storedToken, storedUser)
} catch (e: any) {
console.warn("Failed to log in using stored token.", e)
}

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import PageContainer from "@/components/PageContainer.vue";
import type {Ref} from "vue";
import type {AdminUserInfo} from "@/api/admin";
import {onMounted, ref} from "vue";
import {getAllUsers} from "@/api/admin";
import {useAuthStore} from "@/stores/auth";
const authStore = useAuthStore()
const users: Ref<AdminUserInfo[]> = ref([])
onMounted(async () => {
users.value = await getAllUsers(authStore.token)
})
</script>
<template>
<PageContainer>
<h1>Admin</h1>
<p>
This is the admin page!
</p>
<h3>Users</h3>
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Admin</th>
<th>List Count</th>
<th>Note Count</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.username">
<td v-text="user.username"/>
<td v-text="user.email"/>
<td v-text="user.admin"/>
<td v-text="user.listCount"/>
<td v-text="user.noteCount"/>
</tr>
</tbody>
</table>
</PageContainer>
</template>
<style scoped>
</style>

View File

@ -110,7 +110,7 @@ async function createNoteAndRefresh() {
</div>
<p v-if="list.notes.length === 0">
<em>There are no notes in this list.</em> <Button @click="toggleCreatingNewNote()">Add one!</Button>
<em>There are no notes in this list.</em> <button @click="toggleCreatingNewNote()">Add one!</button>
</p>
<dialog id="list-delete-dialog">