Added more API stuff, front-end stuff too.
This commit is contained in:
parent
57d92d95a4
commit
69ea579ea3
|
@ -14,3 +14,5 @@ litelist-api-test-*
|
|||
*.o
|
||||
*.obj
|
||||
*.lst
|
||||
|
||||
users/
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
],
|
||||
"copyright": "Copyright © 2023, Andrew Lalis",
|
||||
"dependencies": {
|
||||
"botan": "~>1.13.5",
|
||||
"d2sqlite3": "~>1.0.0",
|
||||
"handy-httpd": "~>7.9.3",
|
||||
"jwt": "~>0.4.0",
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
{
|
||||
"fileVersion": 1,
|
||||
"versions": {
|
||||
"botan": "1.13.5",
|
||||
"botan-math": "1.0.4",
|
||||
"d2sqlite3": "1.0.0",
|
||||
"handy-httpd": "7.9.3",
|
||||
"httparsed": "1.2.1",
|
||||
"jwt": "0.4.0",
|
||||
"memutils": "1.0.9",
|
||||
"slf4d": "2.4.2",
|
||||
"streams": "3.5.0"
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ private HttpServer initServer() {
|
|||
import handy_httpd.handlers.path_delegating_handler;
|
||||
import handy_httpd.handlers.filtered_handler;
|
||||
import auth;
|
||||
import lists;
|
||||
|
||||
ServerConfig config = ServerConfig.defaultValues();
|
||||
config.enableWebSockets = false;
|
||||
|
@ -21,20 +22,29 @@ private HttpServer initServer() {
|
|||
config.port = 8080;
|
||||
config.connectionQueueSize = 10;
|
||||
config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
|
||||
config.defaultHeaders["Access-Control-Allow-Credentials"] = "true";
|
||||
config.defaultHeaders["Vary"] = "origin";
|
||||
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization";
|
||||
|
||||
|
||||
auto mainHandler = new PathDelegatingHandler();
|
||||
mainHandler.addMapping(Method.GET, "/status", (ref HttpRequestContext ctx) {
|
||||
ctx.response.writeBodyString("online");
|
||||
});
|
||||
mainHandler.addMapping(Method.POST, "/login", &handleLogin);
|
||||
|
||||
// Authenticated endpoints are protected by the TokenFilter.
|
||||
auto authEndpoints = new PathDelegatingHandler();
|
||||
auto authHandler = new FilteredRequestHandler(
|
||||
authEndpoints,
|
||||
[new TokenFilter]
|
||||
);
|
||||
auto optionsHandler = toHandler((ref HttpRequestContext ctx) {
|
||||
ctx.response.setStatus(HttpStatus.OK);
|
||||
});
|
||||
|
||||
mainHandler.addMapping(Method.POST, "/register", &createNewUser);
|
||||
mainHandler.addMapping(Method.POST, "/login", &handleLogin);
|
||||
mainHandler.addMapping(Method.GET, "/me", &getMyUser);
|
||||
mainHandler.addMapping(Method.OPTIONS, "/**", optionsHandler);
|
||||
mainHandler.addMapping(Method.DELETE, "/me", &deleteMyUser);
|
||||
|
||||
mainHandler.addMapping(Method.GET, "/lists", &getNoteLists);
|
||||
mainHandler.addMapping(Method.POST, "/lists", &createNoteList);
|
||||
mainHandler.addMapping(Method.DELETE, "/lists/{id}", &deleteNoteList);
|
||||
|
||||
return new HttpServer(mainHandler, config);
|
||||
}
|
||||
|
|
|
@ -2,18 +2,95 @@ module auth;
|
|||
|
||||
import handy_httpd;
|
||||
import handy_httpd.handlers.filtered_handler;
|
||||
import jwt.jwt;
|
||||
import jwt.algorithms;
|
||||
import slf4d;
|
||||
|
||||
import std.datetime;
|
||||
import std.json;
|
||||
import std.path;
|
||||
import std.file;
|
||||
import std.typecons;
|
||||
|
||||
import data;
|
||||
|
||||
|
||||
void handleLogin(ref HttpRequestContext ctx) {
|
||||
JSONValue loginData = ctx.request.readBodyAsJson();
|
||||
if ("username" !in loginData.object || "password" !in loginData.object) {
|
||||
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
||||
ctx.response.writeBodyString("Invalid login request data. Expected username and password.");
|
||||
return;
|
||||
}
|
||||
string username = loginData.object["username"].str;
|
||||
infoF!"Got login request for user \"%s\"."(username);
|
||||
string password = loginData.object["password"].str;
|
||||
Nullable!User userNullable = userDataSource.getUser(username);
|
||||
if (userNullable.isNull) {
|
||||
infoF!"User \"%s\" doesn't exist."(username);
|
||||
sendUnauthenticatedResponse(ctx.response);
|
||||
return;
|
||||
}
|
||||
User user = userNullable.get();
|
||||
|
||||
import botan.passhash.bcrypt : checkBcrypt;
|
||||
if (!checkBcrypt(password, user.passwordHash)) {
|
||||
sendUnauthenticatedResponse(ctx.response);
|
||||
return;
|
||||
}
|
||||
|
||||
JSONValue resp = JSONValue(string[string].init);
|
||||
resp.object["token"] = "authtoken";
|
||||
resp.object["token"] = generateToken(user);
|
||||
ctx.response.writeBodyString(resp.toString(), "application/json");
|
||||
}
|
||||
|
||||
struct User {
|
||||
string username;
|
||||
string email;
|
||||
string passwordHash;
|
||||
void createNewUser(ref HttpRequestContext ctx) {
|
||||
JSONValue userData = ctx.request.readBodyAsJson();
|
||||
string username = userData.object["username"].str;
|
||||
string email = userData.object["email"].str;
|
||||
string password = userData.object["password"].str;
|
||||
|
||||
if (!userDataSource.getUser(username).isNull) {
|
||||
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
||||
ctx.response.writeBodyString("Username is taken.");
|
||||
return;
|
||||
}
|
||||
|
||||
import botan.passhash.bcrypt : generateBcrypt;
|
||||
import botan.rng.auto_rng;
|
||||
RandomNumberGenerator rng = new AutoSeededRNG();
|
||||
string passwordHash = generateBcrypt(password, rng, 12);
|
||||
|
||||
userDataSource.createUser(username, email, passwordHash);
|
||||
}
|
||||
|
||||
void getMyUser(ref HttpRequestContext ctx) {
|
||||
if (!validateAuthenticatedRequest(ctx)) return;
|
||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
||||
JSONValue resp = JSONValue(string[string].init);
|
||||
resp.object["username"] = JSONValue(auth.user.username);
|
||||
resp.object["email"] = JSONValue(auth.user.email);
|
||||
ctx.response.writeBodyString(resp.toString(), "application/json");
|
||||
}
|
||||
|
||||
void deleteMyUser(ref HttpRequestContext ctx) {
|
||||
if (!validateAuthenticatedRequest(ctx)) return;
|
||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
||||
userDataSource.deleteUser(auth.user.username);
|
||||
}
|
||||
|
||||
private string generateToken(in User user) {
|
||||
Token token = new Token(JWTAlgorithm.HS512);
|
||||
token.claims.aud("litelist-api");
|
||||
token.claims.sub(user.username);
|
||||
token.claims.exp(Clock.currTime.toUnixTime() + 5000);
|
||||
token.claims.iss("litelist-api");
|
||||
return token.encode("supersecret");// TODO: Extract secret.
|
||||
}
|
||||
|
||||
private void sendUnauthenticatedResponse(ref HttpResponse resp) {
|
||||
resp.setStatus(HttpStatus.UNAUTHORIZED);
|
||||
resp.writeBodyString("Invalid credentials.");
|
||||
}
|
||||
|
||||
struct AuthContext {
|
||||
|
@ -41,32 +118,62 @@ class AuthContextHolder {
|
|||
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;
|
||||
}
|
||||
|
||||
class TokenFilter : HttpRequestFilter {
|
||||
/**
|
||||
* Validates any request that should be authenticated with an access token,
|
||||
* and sets the AuthContextHolder's context if the user is authenticated.
|
||||
* Otherwise, sends an appropriate "unauthorized" response.
|
||||
* Params:
|
||||
* ctx = The request context to validate.
|
||||
* Returns: True if the user is authenticated, or false otherwise.
|
||||
*/
|
||||
bool validateAuthenticatedRequest(ref HttpRequestContext ctx) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
string rawToken = authHeader[7 .. $];
|
||||
string username;
|
||||
try {
|
||||
Token token = verify(rawToken, "supersecret", [JWTAlgorithm.HS512]);
|
||||
username = token.claims.sub;
|
||||
} catch (Exception e) {
|
||||
warn("Failed to verify user token.", e);
|
||||
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid token.");
|
||||
}
|
||||
|
||||
Nullable!User user = userDataSource.getUser(username);
|
||||
if (user.isNull) {
|
||||
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||
ctx.response.writeBodyString("User does not exist.");
|
||||
return false;
|
||||
}
|
||||
|
||||
AuthContextHolder.setContext(rawToken, user.get);
|
||||
return true;
|
||||
}
|
||||
|
||||
class TokenFilter : HttpRequestFilter {
|
||||
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
|
||||
AuthContextHolder.reset();
|
||||
if (!ctx.request.hasHeader(HEADER_NAME)) {
|
||||
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||
ctx.response.writeBodyString("Missing Authorization header.");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
string rawToken = authHeader[7 .. $];
|
||||
|
||||
// TODO: Validate token and fetch user.
|
||||
User user = User("bleh", "bleh@example.com", "faef9834rfe");
|
||||
|
||||
AuthContextHolder.setContext(rawToken, user);
|
||||
filterChain.doFilter(ctx);
|
||||
if (validateAuthenticatedRequest(ctx)) filterChain.doFilter(ctx);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,213 @@
|
|||
module data;
|
||||
|
||||
import handy_httpd;
|
||||
import d2sqlite3;
|
||||
|
||||
import std.path;
|
||||
import std.file;
|
||||
import std.stdio;
|
||||
import std.typecons;
|
||||
import std.string;
|
||||
import std.json;
|
||||
|
||||
static UserDataSource userDataSource;
|
||||
|
||||
static this() {
|
||||
userDataSource = new FsSqliteDataSource();
|
||||
}
|
||||
|
||||
struct User {
|
||||
string username;
|
||||
string email;
|
||||
string passwordHash;
|
||||
}
|
||||
|
||||
struct NoteList {
|
||||
ulong id;
|
||||
string name;
|
||||
uint ordinality;
|
||||
string description;
|
||||
Note[] notes;
|
||||
}
|
||||
|
||||
struct Note {
|
||||
ulong id;
|
||||
ulong noteListId;
|
||||
uint ordinality;
|
||||
string content;
|
||||
}
|
||||
|
||||
interface UserDataSource {
|
||||
User createUser(string username, string email, string passwordHash);
|
||||
void deleteUser(string username);
|
||||
Nullable!User getUser(string username);
|
||||
NoteList[] getLists(string username);
|
||||
NoteList createNoteList(string username, string name, string description = null);
|
||||
void deleteNoteList(string username, ulong id);
|
||||
Note createNote(string username, ulong noteListId, string content);
|
||||
void deleteNote(string username, ulong id);
|
||||
}
|
||||
|
||||
private immutable string USERS_DIR = "users";
|
||||
private immutable string DATA_FILE = "user.json";
|
||||
private immutable string DB_FILE = "notes.sqlite";
|
||||
|
||||
class FsSqliteDataSource : UserDataSource {
|
||||
User createUser(string username, string email, string passwordHash) {
|
||||
string dirPath = buildPath(USERS_DIR, username);
|
||||
if (exists(dirPath)) throw new Exception("User already has a directory.");
|
||||
mkdir(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;
|
||||
std.file.write(dataPath, userObj.toPrettyString());
|
||||
|
||||
// Set up a default list.
|
||||
NoteList defaultList = this.createNoteList(username, "Default", "Your default list of notes.");
|
||||
this.createNote(username, defaultList.id, "Here's an example note that was added to the Default list.");
|
||||
|
||||
return User(username, email, passwordHash);
|
||||
}
|
||||
|
||||
void deleteUser(string username) {
|
||||
string dirPath = buildPath(USERS_DIR, username);
|
||||
if (exists(dirPath)) rmdirRecurse(dirPath);
|
||||
}
|
||||
|
||||
Nullable!User getUser(string username) {
|
||||
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
|
||||
));
|
||||
}
|
||||
return Nullable!User.init;
|
||||
}
|
||||
|
||||
NoteList[] getLists(string username) {
|
||||
Database db = getDb(username);
|
||||
ResultRange results = db.execute("SELECT * FROM note_list ORDER BY ordinality ASC");
|
||||
NoteList[] lists;
|
||||
foreach (Row row; results) {
|
||||
lists ~= parseNoteList(row);
|
||||
}
|
||||
// Now eager-fetch notes for each list.
|
||||
Statement stmt = db.prepare("SELECT * FROM note WHERE note_list_id = ? ORDER BY ordinality ASC");
|
||||
foreach (ref list; lists) {
|
||||
stmt.bind(1, list.id);
|
||||
ResultRange noteResult = stmt.execute();
|
||||
foreach (row; noteResult) list.notes ~= parseNote(row);
|
||||
stmt.reset();
|
||||
}
|
||||
return lists;
|
||||
}
|
||||
|
||||
NoteList createNoteList(string username, string name, string description = null) {
|
||||
Database db = getDb(username);
|
||||
|
||||
Statement existsStatement = db.prepare("SELECT COUNT(name) FROM note_list WHERE name = ?");
|
||||
existsStatement.bind(1, name);
|
||||
ResultRange existsResult = existsStatement.execute();
|
||||
if (existsResult.oneValue!int() > 0) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "List already exists.");
|
||||
|
||||
Nullable!uint ordResult = db.execute("SELECT MAX(ordinality) + 1 FROM note_list").oneValue!(Nullable!uint);
|
||||
uint ordinality = 0;
|
||||
if (!ordResult.isNull) ordinality = ordResult.get();
|
||||
Statement stmt = db.prepare("INSERT INTO note_list (name, ordinality, description) VALUES (?, ?, ?)");
|
||||
stmt.bind(1, name);
|
||||
stmt.bind(2, ordinality);
|
||||
stmt.bind(3, description);
|
||||
stmt.execute();
|
||||
return NoteList(db.lastInsertRowid(), name, ordinality, description, []);
|
||||
}
|
||||
|
||||
void deleteNoteList(string username, ulong id) {
|
||||
Database db = getDb(username);
|
||||
Statement stmt1 = db.prepare("DELETE FROM note WHERE note_list_id = ?");
|
||||
stmt1.bind(1, id);
|
||||
stmt1.execute();
|
||||
Statement stmt2 = db.prepare("DELETE FROM note_list WHERE id = ?");
|
||||
stmt2.bind(1, id);
|
||||
stmt2.execute();
|
||||
}
|
||||
|
||||
Note createNote(string username, ulong noteListId, string content) {
|
||||
Database db = getDb(username);
|
||||
|
||||
Statement ordStmt = db.prepare("SELECT MAX(ordinality) + 1 FROM note WHERE note_list_id = ?");
|
||||
ordStmt.bind(1, noteListId);
|
||||
Nullable!uint ordResult = ordStmt.execute().oneValue!(Nullable!uint);
|
||||
uint ordinality = 0;
|
||||
if (!ordResult.isNull) ordinality = ordResult.get();
|
||||
|
||||
Statement insertStmt = db.prepare("INSERT INTO note (note_list_id, ordinality, content) VALUES (?, ?, ?)");
|
||||
insertStmt.bind(1, noteListId);
|
||||
insertStmt.bind(2, ordinality);
|
||||
insertStmt.bind(3, content);
|
||||
insertStmt.execute();
|
||||
return Note(
|
||||
db.lastInsertRowid(),
|
||||
noteListId,
|
||||
ordinality,
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
void deleteNote(string username, ulong id) {
|
||||
Database db = getDb(username);
|
||||
Statement stmt = db.prepare("DELETE FROM note WHERE id = ?");
|
||||
stmt.bind(1, id);
|
||||
stmt.execute();
|
||||
}
|
||||
|
||||
private NoteList parseNoteList(Row row) {
|
||||
NoteList list;
|
||||
list.id = row["id"].as!ulong;
|
||||
list.name = row["name"].as!string;
|
||||
list.ordinality = row["ordinality"].as!uint;
|
||||
list.description = row["description"].as!string;
|
||||
return list;
|
||||
}
|
||||
|
||||
private Note parseNote(Row row) {
|
||||
Note note;
|
||||
note.id = row["id"].as!ulong;
|
||||
note.noteListId = row["note_list_id"].as!ulong;
|
||||
note.ordinality = row["ordinality"].as!uint;
|
||||
note.content = row["content"].as!string;
|
||||
return note;
|
||||
}
|
||||
|
||||
private Database getDb(string username) {
|
||||
string dbPath = buildPath(USERS_DIR, username, DB_FILE);
|
||||
if (!exists(dbPath)) initDb(dbPath);
|
||||
return Database(dbPath);
|
||||
}
|
||||
|
||||
private void initDb(string path) {
|
||||
if (exists(path)) std.file.remove(path);
|
||||
Database db = Database(path);
|
||||
db.run(q"SQL
|
||||
CREATE TABLE note_list (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
ordinality INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE note (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_list_id INTEGER NOT NULL,
|
||||
ordinality INTEGER NOT NULL DEFAULT 0,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
SQL"
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
module lists;
|
||||
|
||||
import handy_httpd;
|
||||
import std.json;
|
||||
|
||||
import auth;
|
||||
import data;
|
||||
|
||||
void getNoteLists(ref HttpRequestContext ctx) {
|
||||
if (!validateAuthenticatedRequest(ctx)) return;
|
||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
||||
NoteList[] lists = userDataSource.getLists(auth.user.username);
|
||||
JSONValue listsArray = JSONValue(string[].init);
|
||||
foreach (NoteList list; lists) {
|
||||
listsArray.array ~= serializeList(list);
|
||||
}
|
||||
ctx.response.writeBodyString(listsArray.toString(), "application/json");
|
||||
}
|
||||
|
||||
void createNoteList(ref HttpRequestContext ctx) {
|
||||
if (!validateAuthenticatedRequest(ctx)) return;
|
||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
||||
JSONValue requestBody = ctx.request.readBodyAsJson();
|
||||
string listName = requestBody.object["name"].str;
|
||||
string description = requestBody.object["description"].str;
|
||||
NoteList list = userDataSource.createNoteList(auth.user.username, listName, description);
|
||||
ctx.response.writeBodyString(serializeList(list).toString(), "application/json");
|
||||
}
|
||||
|
||||
void deleteNoteList(ref HttpRequestContext ctx) {
|
||||
if (!validateAuthenticatedRequest(ctx)) return;
|
||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
||||
userDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id"));
|
||||
}
|
||||
|
||||
private JSONValue serializeList(NoteList list) {
|
||||
JSONValue listObj = JSONValue(string[string].init);
|
||||
listObj.object["id"] = JSONValue(list.id);
|
||||
listObj.object["name"] = JSONValue(list.name);
|
||||
listObj.object["ordinality"] = JSONValue(list.ordinality);
|
||||
listObj.object["description"] = JSONValue(list.description);
|
||||
listObj.object["notes"] = JSONValue(string[].init);
|
||||
foreach (Note note; list.notes) {
|
||||
listObj.object["notes"].array ~= serializeNote(note);
|
||||
}
|
||||
return listObj;
|
||||
}
|
||||
|
||||
private JSONValue serializeNote(Note note) {
|
||||
JSONValue noteObj = JSONValue(string[string].init);
|
||||
noteObj.object["id"] = JSONValue(note.id);
|
||||
noteObj.object["ordinality"] = JSONValue(note.ordinality);
|
||||
noteObj.object["noteListId"] = JSONValue(note.noteListId);
|
||||
noteObj.object["content"] = JSONValue(note.content);
|
||||
return noteObj;
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import {API_URL} from "@/api/base";
|
||||
|
||||
export interface User {
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface LoginInfo {
|
||||
user: User
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface LoginError {
|
||||
message: string
|
||||
}
|
||||
|
||||
interface LoginTokenResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginInfo> {
|
||||
let response: Response | null = null
|
||||
try {
|
||||
response = await fetch(
|
||||
API_URL + "/login",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({username: username, password: password})
|
||||
}
|
||||
)
|
||||
} catch (error: any) {
|
||||
throw {message: "Request failed: " + error.message}
|
||||
}
|
||||
if (response.ok) {
|
||||
const content: LoginTokenResponse = await response.json()
|
||||
const token = content.token
|
||||
const userResponse = await fetch(API_URL + "/me", {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + token
|
||||
}
|
||||
})
|
||||
const user: User = await userResponse.json()
|
||||
return {token: token, user: user}
|
||||
} else if (response.status < 500) {
|
||||
throw {message: "Invalid credentials."}
|
||||
} else {
|
||||
throw {message: "Server error. Try again later."}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export const API_URL = "http://localhost:8080"
|
|
@ -0,0 +1,27 @@
|
|||
import {API_URL} from "@/api/base";
|
||||
|
||||
export interface Note {
|
||||
id: number
|
||||
ordinality: number
|
||||
content: string
|
||||
noteListName: string
|
||||
}
|
||||
|
||||
export interface NoteList {
|
||||
name: string
|
||||
ordinality: number
|
||||
description: string
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
export async function getNoteLists(token: string): Promise<NoteList[]> {
|
||||
const response = await fetch(API_URL + "/lists", {
|
||||
headers: {"Authorization": "Bearer " + token}
|
||||
})
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
} else {
|
||||
console.error(response)
|
||||
return []
|
||||
}
|
||||
}
|
|
@ -1,13 +1,31 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import LoginView from "@/views/LoginView.vue";
|
||||
import ListsView from "@/views/ListsView.vue";
|
||||
import {useAuthStore} from "@/stores/auth";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
path: "/",
|
||||
name: "home-redirect",
|
||||
redirect: to => {
|
||||
return "login"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: LoginView
|
||||
},
|
||||
{
|
||||
path: "/lists",
|
||||
name: "lists",
|
||||
component: ListsView,
|
||||
beforeEnter: (to, from) => {
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.authenticated) return "login"
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import {defineStore} from "pinia";
|
||||
import {type Ref, ref} from "vue";
|
||||
import type {User} from "@/api/auth";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const authenticated: Ref<boolean> = ref(false)
|
||||
const user: Ref<User | null> = ref(null)
|
||||
const token: Ref<string | null> = ref(null)
|
||||
|
||||
function logIn(newToken: string, newUser: User) {
|
||||
authenticated.value = true
|
||||
user.value = newUser
|
||||
token.value = newToken
|
||||
}
|
||||
|
||||
function logOut() {
|
||||
authenticated.value = false
|
||||
user.value = null
|
||||
token.value = null
|
||||
}
|
||||
|
||||
return {authenticated, user, token, logIn, logOut}
|
||||
})
|
||||
|
||||
export type AuthStore = typeof useAuthStore
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import {useAuthStore} from "@/stores/auth";
|
||||
import {onMounted, ref, type Ref} from "vue";
|
||||
import type {NoteList} from "@/api/lists";
|
||||
import {getNoteLists} from "@/api/lists";
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const noteLists: Ref<NoteList[]> = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
noteLists.value = await getNoteLists(authStore.token)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>
|
||||
Lists
|
||||
</h1>
|
||||
<p>
|
||||
Here are your lists!
|
||||
</p>
|
||||
<div v-for="list in noteLists" :key="list.id">
|
||||
<h3 v-text="list.name"></h3>
|
||||
<ul>
|
||||
<li v-for="note in list.notes" :key="note.id" v-text="note.content"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -1,11 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import {ref} from "vue";
|
||||
import {login, type LoginError} from "@/api/auth";
|
||||
import {useAuthStore} from "@/stores/auth";
|
||||
import {useRouter} from "vue-router";
|
||||
|
||||
const loginModel = ref({
|
||||
username: "",
|
||||
password: ""
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
function resetLogin() {
|
||||
loginModel.value.username = ""
|
||||
loginModel.value.password = ""
|
||||
|
@ -13,17 +19,11 @@ function resetLogin() {
|
|||
|
||||
async function doLogin() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"http://localhost:8080/login",
|
||||
{
|
||||
method: "POST",
|
||||
mode: "no-cors",
|
||||
body: JSON.stringify(loginModel.value)
|
||||
}
|
||||
)
|
||||
console.log(response.json())
|
||||
} catch (error: AxiosError) {
|
||||
console.error(error)
|
||||
const info = await login(loginModel.value.username, loginModel.value.password)
|
||||
authStore.logIn(info.token, info.user)
|
||||
await router.push("lists")
|
||||
} catch (error: any) {
|
||||
console.error(error.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
Loading…
Reference in New Issue