Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add login interface #13 #39

Merged
merged 12 commits into from
Aug 14, 2024
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Add login interface [#13](https://github.com/archesproject/arches-lingo/issues/13)

### Fixed

### Deprecated

### Removed

### Security
6 changes: 6 additions & 0 deletions arches_lingo/media/js/views/root.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import LingoApp from '@/arches_lingo/App.vue';
import createVueApplication from 'arches/arches/app/media/js/utils/create-vue-application';

createVueApplication(LingoApp).then(vueApp => {
vueApp.mount('#lingo-mounting-point');
});
17 changes: 17 additions & 0 deletions arches_lingo/src/arches_lingo/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup lang="ts">
import Toast from "primevue/toast";

import ProgressSpinner from "primevue/progressspinner";

import PageSwitcher from "@/arches_lingo/pages/PageSwitcher.vue";
</script>

<template>
<Suspense>
<PageSwitcher />
<template #fallback>
<ProgressSpinner style="display: flex; margin-top: 8rem" />
</template>
</Suspense>
<Toast />
</template>
56 changes: 56 additions & 0 deletions arches_lingo/src/arches_lingo/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import arches from "arches";
import Cookies from "js-cookie";

function getToken() {
const token = Cookies.get("csrftoken");
if (!token) {
throw new Error("Missing csrftoken");
chrabyrd marked this conversation as resolved.
Show resolved Hide resolved
}
return token;
}

export const login = async (username: string, password: string) => {
const response = await fetch(arches.urls.api_login, {
method: "POST",
headers: { "X-CSRFTOKEN": getToken() },
body: JSON.stringify({ username, password }),
});
try {
const parsed = await response.json();
if (response.ok) {
return parsed;
}
throw new Error(parsed.message);
} catch (error) {
throw new Error((error as Error).message || response.statusText);
}
};

export const logout = async () => {
const response = await fetch(arches.urls.api_logout, {
method: "POST",
headers: { "X-CSRFTOKEN": getToken() },
});
if (response.ok) {
return true;
}
try {
const error = await response.json();
throw new Error(error.message);
} catch (error) {
throw new Error((error as Error).message || response.statusText);
}
};

export const fetchUser = async () => {
const response = await fetch(arches.urls.api_user);
try {
const parsed = await response.json();
if (response.ok) {
return parsed;
}
throw new Error(parsed.message);
} catch (error) {
throw new Error((error as Error).message || response.statusText);
}
};
7 changes: 7 additions & 0 deletions arches_lingo/src/arches_lingo/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { InjectionKey } from "vue";
import type { UserRefAndSetter } from "@/arches_lingo/types";

export const ERROR = "error";
export const DEFAULT_ERROR_TOAST_LIFE = 8000;

export const userKey = Symbol() as InjectionKey<UserRefAndSetter>;
55 changes: 55 additions & 0 deletions arches_lingo/src/arches_lingo/pages/HomePage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { useGettext } from "vue3-gettext";

import { useToast } from "primevue/usetoast";
import Button from "primevue/button";

import { DEFAULT_ERROR_TOAST_LIFE, ERROR } from "@/arches_lingo/constants.ts";
import { logout } from "@/arches_lingo/api.ts";
import { userKey } from "@/arches_lingo/constants.ts";

import type { UserRefAndSetter } from "@/arches_lingo/types";

const { user, setUser } = inject(userKey) as UserRefAndSetter;

const { $gettext } = useGettext();
const toast = useToast();

const issueLogout = async () => {
try {
await logout();
setUser(null);
} catch (error) {
toast.add({
severity: ERROR,
life: DEFAULT_ERROR_TOAST_LIFE,
summary: $gettext("Sign out failed."),
detail: error instanceof Error ? error.message : undefined,
});
}
};

const bestName = computed(() => {
if (!user.value) {
return "";
}
// TODO: determine appropriate i18n for this.
if (user.value.first_name && user.value.last_name) {
return user.value.first_name + " " + user.value.last_name;
}
return user.value.username;
});
</script>

<template>
<main>
<div style="display: flex; justify-content: space-between">
<h1>{{ $gettext("LINGO") }}</h1>
<span>{{ $gettext("Hello %{bestName}", { bestName }) }}</span>
<Button @click="issueLogout">
{{ $gettext("Sign out") }}
</Button>
</div>
</main>
</template>
44 changes: 44 additions & 0 deletions arches_lingo/src/arches_lingo/pages/PageSwitcher.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { provide, ref } from "vue";
import { useGettext } from "vue3-gettext";

import { useToast } from "primevue/usetoast";

import { fetchUser } from "@/arches_lingo/api.ts";
import {
DEFAULT_ERROR_TOAST_LIFE,
ERROR,
userKey,
} from "@/arches_lingo/constants.ts";
import HomePage from "@/arches_lingo/pages/HomePage.vue";
import LoginPage from "@/arches_lingo/pages/login/LoginPage.vue";

import type { User } from "@/arches_lingo/types";

const { $gettext } = useGettext();
const toast = useToast();

const user = ref<User | null>(null);
const setUser = (userToSet: User | null) => {
user.value = userToSet;
};
provide(userKey, { user, setUser });

try {
setUser(await fetchUser());
} catch (error) {
toast.add({
severity: ERROR,
life: DEFAULT_ERROR_TOAST_LIFE,
summary: $gettext("Login required"), // most likely case is inactive user
detail: error instanceof Error ? error.message : undefined,
});
}
</script>

<template>
<div style="font-family: sans-serif">
<HomePage v-if="user && user.username !== 'anonymous'" />
<LoginPage v-else />
Comment on lines +41 to +42
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will change on the routing branch. For now this is just to get something functional to test with.

</div>
</template>
15 changes: 15 additions & 0 deletions arches_lingo/src/arches_lingo/pages/login/LoginPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import LoginForm from "@/arches_lingo/pages/login/components/LoginForm.vue";
import LoginLinks from "@/arches_lingo/pages/login/components/LoginLinks.vue";
</script>

<template>
<div style="margin: 5%">
<LoginForm />
<div
class="spacer"
style="height: 10rem"
></div>
<LoginLinks />
</div>
</template>
78 changes: 78 additions & 0 deletions arches_lingo/src/arches_lingo/pages/login/components/LoginForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { inject, ref } from "vue";
import { useGettext } from "vue3-gettext";
import { useToast } from "primevue/usetoast";
import InputText from "primevue/inputtext";
import Button from "primevue/button";
import { login } from "@/arches_lingo/api.ts";
import {
DEFAULT_ERROR_TOAST_LIFE,
ERROR,
userKey,
} from "@/arches_lingo/constants.ts";
import type { UserRefAndSetter } from "@/arches_lingo/types";
const { $gettext } = useGettext();
const toast = useToast();
const { setUser } = inject(userKey) as UserRefAndSetter;
const username = ref();
const password = ref();
const submit = async () => {
try {
const userToSet = await login(username.value, password.value);
setUser(userToSet);
} catch (error) {
toast.add({
severity: ERROR,
life: DEFAULT_ERROR_TOAST_LIFE,
summary: $gettext("Sign in failed."),
detail: error instanceof Error ? error.message : undefined,
});
}
};
</script>

<template>
<form>
<h1>{{ $gettext("LINGO") }}</h1>
<h2>{{ $gettext("Vocabulary management powered by Arches.") }}</h2>
<InputText
v-model="username"
:placeholder="$gettext('Username')"
:aria-label="$gettext('Username')"
autocomplete="username"
/>
<InputText
v-model="password"
:placeholder="$gettext('Password')"
:aria-label="$gettext('Password')"
type="password"
autocomplete="password"
@keyup.enter="submit"
/>

<Button
type="button"
:label="$gettext('Sign In')"
@click="submit"
/>
</form>
</template>

<style scoped>
form {
display: flex;
flex-direction: column;
gap: 1rem;
width: 30%;
}
input {
width: 100%;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script setup lang="ts">
import arches from "arches";
import { useGettext } from "vue3-gettext";

import Button from "primevue/button";

const { $gettext } = useGettext();
</script>

<template>
<div style="display: flex; justify-content: space-between; width: 30%">
<Button
as="a"
:label="$gettext('Register')"
:href="arches.urls.signup"
/>
<Button
as="a"
:label="$gettext('Multi-factor login')"
:href="arches.urls.auth + '?next=/'"
/>
</div>
</template>
13 changes: 13 additions & 0 deletions arches_lingo/src/arches_lingo/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Ref } from "vue";

export interface User {
first_name: string;
last_name: string;
username: string;
}

// Prop injection types
export interface UserRefAndSetter {
user: Ref<User | null>;
setUser: (userToSet: User | null) => void;
}
2 changes: 2 additions & 0 deletions arches_lingo/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View

from arches.app.models.models import (
Expand Down Expand Up @@ -200,6 +201,7 @@ def get(self, request):


class LingoRootView(BaseManagerView):
@method_decorator(ensure_csrf_cookie)
def get(self, request, graphid=None, resourceid=None):
context = self.get_context_data(main_script="views/root")
context["page_title"] = _("Lingo")
Expand Down
Loading
Loading