1
0
Fork 0
forked from TWS/kalkutago

Compare commits

...

18 commits

Author SHA1 Message Date
scott 0051d87d47 Merge pull request 'Feature: User auth' (#15) from scott/kalkutago:feature/user-auth into main
Reviewed-on: https://git.tams.tech/TWS/kalkutago/pulls/15
2023-08-27 12:00:56 +00:00
D. Scott Boggs 033f2a561b Nit 2023-08-26 14:51:14 -04:00
D. Scott Boggs 019ccda845 Rename variable 2023-08-26 14:50:05 -04:00
D. Scott Boggs bc6f06e210 DEFAULT_COST + 2 is 2 much 2023-08-26 11:21:36 -04:00
D. Scott Boggs 424bc15512 prod needs the cookie secret too, arguably more than the others! 2023-08-26 11:18:23 -04:00
D. Scott Boggs 89b0180989 Move API calls from state to Track methods 2023-08-26 11:05:15 -04:00
D. Scott Boggs cd16208dd7 Move dateQuery to util.ts 2023-08-26 11:01:14 -04:00
D. Scott Boggs dafdd491f9 Move logOut function into method on state 2023-08-26 07:03:16 -04:00
D. Scott Boggs 003383e455 Fixed logout flow 2023-08-26 06:54:42 -04:00
D. Scott Boggs 5aa16762f7 Quick tweak 2023-08-26 06:54:17 -04:00
D. Scott Boggs db3ef7640a Renamed a variable
sorry it was bothering me.
2023-08-26 06:54:06 -04:00
scott f32a188750 Merge pull request 'Frontend was merged prematurely, this fixes that' (#3) from frontend/feature/login-view into feature/user-auth
Reviewed-on: https://git.tams.tech/scott/kalkutago/pulls/3
2023-08-26 10:18:06 +00:00
D. Scott Boggs bfffacabf6 Add logout button 2023-08-26 06:17:42 -04:00
D. Scott Boggs 290218eefe Restore logged-in state on page reload 2023-08-26 06:17:42 -04:00
D. Scott Boggs ffc1c6806a Fix shell.nix 2023-08-26 06:17:42 -04:00
D. Scott Boggs 37426aaa52 WIP: Add busy indicators to login view 2023-08-26 06:17:42 -04:00
D. Scott Boggs db72a6df17 Add busy-indicator to login/signup buttons 2023-08-26 06:17:42 -04:00
scott 3dbe2d2327 Merge pull request 'Backend feature: user auth' (#1) from backend/feature/user-auth into feature/user-auth
Reviewed-on: https://git.tams.tech/scott/kalkutago/pulls/1
2023-08-26 10:15:14 +00:00
14 changed files with 154 additions and 83 deletions

View file

@ -7,6 +7,7 @@ client/dist/index.html:
build-client: client/dist/index.html
start-server: build-client
-mkdir db.mount
docker compose up --build -d
clean:

View file

@ -1,5 +1,7 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import { state } from '../state'
</script>
<template>
<nav class="navbar" role="navigation" aria-label="main navigation">
@ -9,20 +11,27 @@ import { RouterLink } from 'vue-router';
<div class="navbar-menu"></div>
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<RouterLink to="/" v-if="$route.path === '/new-track'">
<button class="button is-info">
Go Back
</button>
</RouterLink>
<RouterLink to="/" v-if="$route.path === '/new-track'">
<button class="button is-info">
Go Back
</button>
</RouterLink>
<RouterLink to="/new-track" v-else>
<button class="button is-primary">
Add Track
</button>
</RouterLink>
</div>
<RouterLink to="/new-track" v-else>
<button class="button is-primary">
Add Track
</button>
</RouterLink>
</div>
<div class="navbar-item">
<button class="button is-info" @click="state.logOut()">
Log Out
</button>
</div>
<div class="navbar-item">
<!-- spacer -->
&nbsp;
</div>
</div>
</nav>
</template>
</template>

View file

@ -15,8 +15,8 @@ const className = computed(() => isSet.value ? "button is-rounded is-info" : "bu
async function toggle() {
if (isSet.value) {
await state.taskMarkedIncomplete(props.track, props.date)
await props.track.markIncomplete(props.date)
} else
await state.taskCompleted(props.track, props.date)
await props.track.markComplete(props.date)
}
</script>

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
import { state } from '../state';
import { Track } from '../track';
const props = defineProps<{icon: String, id: number|undefined}>()
const del = () => {
if(props.id)
if(confirm("are you sure you want to delete this track?"))
state.removeTrack(props.id)
Track.deleteById(props.id)
}
</script>
<template>

View file

@ -2,6 +2,8 @@ import { reactive } from "vue"
import { Track } from "./track"
import { Tick } from './ticks'
import { error } from "./error"
import { getCookie } from "./util";
import router from './router'
enum State {
Unfetched,
@ -9,15 +11,6 @@ enum State {
Fetched,
}
function dateQuery(date: Date): URLSearchParams {
let query = new URLSearchParams()
query.set("year", date.getUTCFullYear().toString())
query.set("month", (date.getUTCMonth() + 1).toString())
// good thing I still had this ^^^^^^^^^^^^^^ in mind when I wrote this 😬
query.set("day", date.getUTCDate().toString())
return query
}
interface LoggedInUser {
name: string
}
@ -31,6 +24,8 @@ class AppState {
constructor() {
this.tracks = new Array<Track>
this.state = State.Unfetched
const name = getCookie("name")
if (name) this.user = { name }
}
streamUpdatesFromServer() {
const source = new EventSource("/api/v1/updates")
@ -97,36 +92,13 @@ class AppState {
if (this.state != State.Unfetched) return
await this.repopulate()
}
async taskCompleted(track: Track, date: Date): Promise<Tick> {
const query = dateQuery(date)
const response: Response = await fetch(`/api/v1/tracks/${track.id}/ticked?${query.toString()}`, { method: "PATCH" })
const body = await response.text()
if (!response.ok) {
error(body)
throw new Error(`error setting tick for track ${track.id} ("${track.name}"): ${response.status} ${response.statusText}`)
}
return JSON.parse(body)
}
async taskMarkedIncomplete(track: Track, date: Date) {
const query = dateQuery(date)
const { ok, status, statusText } = await fetch(`/api/v1/tracks/${track.id}/all-ticks?${query.toString()}`, { method: 'DELETE' })
if (!ok)
error(`error deleting ticks for ${track.id}: ${statusText} (${status})`)
}
async addTrack(track: Track): Promise<boolean> {
const response = await fetch('/api/v1/tracks', {
method: "POST",
body: JSON.stringify(track),
headers: { "Content-Type": "application/json" }
})
if (!response.ok)
error(`error submitting track: ${track}: ${response.statusText} (${response.status})`)
return response.ok
}
async removeTrack(trackID: number) {
const response = await fetch(`/api/v1/tracks/${trackID}`, { method: "DELETE" })
if (!response.ok) error(`error deleting track with ID ${trackID}: ${response.statusText} (${response.status})`)
async logOut() {
const result = await fetch('/api/v1/auth', {method: 'DELETE'})
if(!result.ok) return error('failed to log out')
this.user = undefined
router.push('/login')
}
}
export const state = reactive(new AppState)

View file

@ -1,5 +1,6 @@
import { error } from "./error"
import { Tick, ITick } from './ticks'
import { dateQuery } from "./util"
export interface ITrack {
id?: number
@ -48,6 +49,34 @@ export class Track implements ITrack {
this.fetchTicks = this.fetchTicks.bind(this)
}
/**
* Add this track to the database. A `TrackAdded` event should have been
* received from the server on the event stream by the time this returns.
*
* @returns whether or not the query succeeded
*/
async create(): Promise<boolean> {
// note that this.id is expected to be `undefined` here.
const response = await fetch('/api/v1/tracks', {
method: "POST",
body: JSON.stringify(this),
headers: { "Content-Type": "application/json" }
})
if (!response.ok)
error(`error submitting track ${this.name}: ${response.statusText} (${response.status})`)
return response.ok
}
async delete() {
const id = this.id
if (id) await Track.deleteById(id)
}
static async deleteById(id: number) {
const response = await fetch(`/api/v1/tracks/${id}`, { method: "DELETE" })
if (!response.ok) error(`error deleting track with ID ${id}: ${response.statusText} (${response.status})`)
}
static fromJSON(track: ITrack): Track {
return new Track(track.id, track.name, track.description, track.icon, track.enabled, track.multiple_entries_per_day, track.color, track.order)
}
@ -98,4 +127,36 @@ export class Track implements ITrack {
}
return []
}
/**
* Mark this track as being completed on the given date. A `TickAdded` event
* should have been received from the server on the event stream by the time
* this returns.
*
* @param date the date the task was completed
* @returns the decoded server API response
*/
async markComplete(date: Date) {
const query = dateQuery(date)
const response: Response = await fetch(`/api/v1/tracks/${this.id}/ticked?${query.toString()}`, { method: "PATCH" })
const body = await response.text()
if (!response.ok) {
error(body)
throw new Error(`error setting tick for track ${this.id} ("${this.name}"): ${response.status} ${response.statusText}`)
}
return JSON.parse(body)
}
/**
* Mark this track as being incomplete on the given date. A `TickAdded` event
* should have been received from the server on the event stream by the time
* this returns.
*
* @param date the date the task was completed
* @returns the decoded server API response
*/
async markIncomplete(date: Date) {
const query = dateQuery(date)
const { ok, status, statusText } = await fetch(`/api/v1/tracks/${this.id}/all-ticks?${query.toString()}`, { method: 'DELETE' })
if (!ok)
error(`error deleting ticks for ${this.id}: ${statusText} (${status})`)
}
}

17
client/src/util.ts Normal file
View file

@ -0,0 +1,17 @@
export function getCookie(key: string): string | null {
const start = document.cookie.indexOf(key + '=')
if(start === -1) return null
let end: number | undefined = document.cookie.indexOf(';', start)
if(end === -1)
end = undefined
return document.cookie.substring(start + key.length + 1, end)
}
export function dateQuery(date: Date): URLSearchParams {
let query = new URLSearchParams()
query.set("year", date.getUTCFullYear().toString())
query.set("month", (date.getUTCMonth() + 1).toString())
// good thing I still had this ^^^^^^^^^^^^^^ in mind when I wrote this 😬
query.set("day", date.getUTCDate().toString())
return query
}

View file

@ -1,39 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { state } from '../state';
import router from '../router'
const name = ref("")
const password = ref("")
const $name = ref("")
const $password = ref("")
const signUpWait = ref(false)
const loginWait = ref(false)
const signUpClass = computed(() => `submit button is-success ${signUpWait.value ? 'is-loading' : ''}`)
const loginClass = computed(() => `submit button is-info ${loginWait.value ? 'is-loading' : ''}`)
async function signUp() {
const $name = name.value
const name = $name.value, password = $password.value
signUpWait.value = true
const result = await fetch("/api/v1/auth", {
method: 'POST',
body: JSON.stringify({ name: $name, password: password.value }),
body: JSON.stringify({ name, password }),
headers: {'Content-Type': 'application/json'}
})
if (result.ok) {
state.user = { name: $name }
state.user = { name }
await state.repopulate()
router.push("/")
}
}
async function login() {
const $name = name.value
const name = $name.value, password = $password.value
loginWait.value = true
const result = await fetch("/api/v1/auth", {
method: 'PUT',
body: JSON.stringify({ name: $name, password: password.value }),
body: JSON.stringify({ name, password }),
headers: {'Content-Type': 'application/json'}
})
if (result.ok) {
state.user = { name: $name }
state.user = { name }
await state.repopulate()
router.push("/")
}
}
if(state.user?.name) router.push("/")
</script>
<template>
<div class="modal is-active">
@ -44,22 +52,22 @@ async function login() {
</header>
<section class="modal-card-body">
<div class="field">
<label for="username">Name</label>
<label for="username" class=label>Name</label>
<div class="control">
<input type="text" name="username" class="input" v-model="name" />
<input type="text" name="username" class="input" v-model="$name" />
</div>
</div>
<div class="field">
<label for="password" class="label">Password</label>
<div class="control">
<input type="password" name="password" class="input" v-model="password" />
<input type="password" name="password" class="input" v-model="$password" />
</div>
</div>
</section>
<footer class="modal-card-foot">
<button class="submit button is-success" @click="login">Log in</button>
<button class="submit button is-info" @click="signUp">Sign Up</button>
<button :class="loginClass" @click="login">Log in</button>
<button :class="signUpClass" @click="signUp">Sign Up</button>
</footer>
</div>
</div>

View file

@ -2,7 +2,6 @@
import { RouterLink, useRouter } from 'vue-router';
import { Track } from '../track';
import { computed, ref } from 'vue';
import { state } from '../state';
const props = defineProps<{ initialState?: Track }>()
const router = useRouter()
@ -26,7 +25,7 @@ const submit = async () => {
const track = new Track(undefined, name.value, description.value,
icon.value, Number(enabled.value), Number(multipleEntriesPerDay.value),
color.value, order.value)
if (await state.addTrack(track))
if (await track.create())
router.push('/')
}
</script>

View file

@ -14,7 +14,7 @@ services:
POSTGRES_USER: kalkutago
POSTGRES_DB: kalkutago
POSTGRES_HOST: database
secrets: [ postgres-password ]
secrets: [ postgres-password, cookie-secret ]
depends_on: [ database ]
volumes:
- ./client/dist:/src/public:ro

View file

@ -32,7 +32,7 @@ async fn get_track_check_user(
track_id: i32,
user: &users::Model,
) -> Result<Json<tracks::Model>, Either<Status, api::ErrorResponder>> {
if let Some(Some(user)) = user
if let Some(Some(track)) = user
.find_related(Tracks)
.filter(tracks::Column::Id.eq(track_id))
.one(db)
@ -40,7 +40,7 @@ async fn get_track_check_user(
.transpose()
.map(|it| it.ok())
{
Ok(Json(user))
Ok(Json(track))
} else {
Err(Left(Status::NotFound))
}

View file

@ -95,6 +95,7 @@ impl Update {
if receiver_count > 0 {
trace!(receiver_count = receiver_count, update = as_serde!(self); "sending update");
let count = tx.send(self.clone())?;
trace!(count = count; "update sent");
} else {
trace!("no update receivers, skipping message");
}

View file

@ -53,7 +53,7 @@ impl ActiveModel {
pub fn new(name: impl AsRef<str>, password: impl AsRef<str>) -> error::Result<Self> {
use sea_orm::ActiveValue::Set;
let name = Set(name.as_ref().to_string());
let password_hash = Set(hash(password.as_ref(), DEFAULT_COST + 2)?);
let password_hash = Set(hash(password.as_ref(), DEFAULT_COST)?);
Ok(Self {
name,
password_hash,

View file

@ -2,13 +2,16 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs.buildPackages; [
clang
yarn nodejs
openssl
python3
python3Packages.requests
python3Packages.ipython
name = "kalkutago";
nativeBuildInputs = with pkgs.buildPackages; [
clang
yarn nodejs
openssl
python3
python3Packages.requests
python3Packages.ipython
rustup
docker
gnumake
];
}