Markdown editor in progress + Login and session process completed

This commit is contained in:
Peaceultime 2024-08-19 16:27:09 +02:00
parent aba56bb034
commit 2e92c389a2
74 changed files with 1305 additions and 313 deletions

View File

@ -13,7 +13,7 @@ const { id: project, home, get } = useProject();
if(project.value !== 0 && home.value === null)
{
const { data: useless } = await useAsyncData(`project:get:${project}`, get);
await useAsyncData(`project:get:${project}`, get);
}
const toggled = ref(false);
@ -50,8 +50,8 @@ onMounted(() => {
<NuxtLink class="arrow-group-item" aria-label="Projets" :to="{ path: '/explorer', force: true }">Liste des projets</NuxtLink>
</div>
</div>
<NuxtLink class="site-nav-bar-text mobile-hidden" aria-label="Outils" :to="{ path: '/tools', force: true }"
:class="{'mod-active': $route.path.startsWith('/tools')}">Outils</NuxtLink>
<NuxtLink class="site-nav-bar-text mobile-hidden" aria-label="Editeur" :to="{ path: '/editing', force: true }"
:class="{'mod-active': $route.path.startsWith('/editing')}">Editeur</NuxtLink>
</div>
</div>
<div class="mobile-bigger">

View File

@ -227,7 +227,7 @@ html.light-mode .light-block {
padding: 4px 1em;
}
.input-group .input-error {
.input-error {
padding: .5em 1em 4px;
color: var(--text-error);
user-select: text;

View File

@ -342,7 +342,7 @@
--inline-title-margin-bottom: 0.5em;
/* Inputs */
--input-height: 30px;
--input-radius: 5px;
--input-radius: 0px;
--input-font-weight: var(--font-normal);
--input-border-width: 1px;
/* Italic */

BIN
bun.lockb

Binary file not shown.

View File

@ -0,0 +1,105 @@
<style>
.editor
{
white-space: pre-line;
overflow: auto;
outline: none;
box-shadow: none !important;
}
</style>
<template>
<div class="editor" contenteditable>
<template
v-if="model && model.length > 0">
<MarkdownRenderer
v-if="node"
:key="key"
:node="node"
:proses="{
'a': LiveA,
'h1': LiveH1,
'h2': LiveH2,
'h3': LiveH3,
'h4': LiveH4,
'h5': LiveH5,
'h6': LiveH6,
'blockquote': LiveBlockquote,
}"
></MarkdownRenderer>
</template>
</div>
</template>
<script setup lang="ts">
import LiveA from "~/components/prose/live/LiveA.vue";
import LiveH1 from "~/components/prose/live/LiveH1.vue";
import LiveH2 from "~/components/prose/live/LiveH2.vue";
import LiveH3 from "~/components/prose/live/LiveH3.vue";
import LiveH4 from "~/components/prose/live/LiveH4.vue";
import LiveH5 from "~/components/prose/live/LiveH5.vue";
import LiveH6 from "~/components/prose/live/LiveH6.vue";
import LiveBlockquote from "~/components/prose/ProseBlockquote.vue";
import { hash } from 'ohash'
import { watch, computed } from 'vue'
import type { Root } from 'hast';
import { diffLines as diff } from 'diff';
const model = defineModel<string>();
const parser = useMarkdown();
const key = computed(() => hash(model.value));
const node = ref<Root>();
watch(model, async (value, old) => {
if(value && old)
{
if(node.value)
{
let content = "", line = 0, pos = -1, len = 0, child;
const d = diff(old, value);
const children = node.value?.children.filter(e => e.hasOwnProperty('position'));
for(let i = 0; i < d.length; i++)
{
if(d[i].added) //Nouvelle ligne
{
const next = d.length > i ? d[i + 1] : undefined;
if(pos === -1 && (!next || !next.removed)) //Nouvelle ligne
{
child = children.filter(e => e.position?.start.line <= line && e.position?.end.line >= line); //Je cherche tout les blocs qui était inclus dans les lignes éditées.
if(child.length > 0)
{
pos = child[0].position?.start.offset ?? 0; //Je pars du premier caractère du bloc
len += (child[child.length - 1].position?.end.offset ?? 0) + 1; //Je m'arrete au dernier caractère du bloc + le \n
}
}
len += d[i].value.length; // J'ajoute le nouveau nombre de caractère
}
else if(d[i].removed) //Ancienne ligne
{
child = children.filter(e => e.position?.start.line <= line + 1 && e.position?.end.line >= line + (d[i].count ?? 1)); //Je cherche tout les blocs qui était inclus dans les lignes éditées.
if(child.length > 0)
{
pos = child[0].position?.start.offset ?? 0; //Je pars du premier caractère du bloc
len += child[child.length - 1].position?.end.offset ?? 0 + 1; //Je m'arrete au dernier caractère du bloc
len -= d[i].value.length; //Je supprime l'ancien nombre de caractère
}
}
else
{
line += d[i].count ?? 0;
}
}
node.value = parser(value);
}
else
{
node.value = parser(value);
}
}
}, { immediate: true });
</script>

View File

@ -8,6 +8,8 @@ const props = defineProps<Prop>();
const model = defineModel<string>();
const err = ref<string | boolean | undefined>(props.error);
watchEffect(() => err.value = props.error);
</script>
<template>

View File

@ -13,6 +13,7 @@ const { data: navigation, execute, status, error } = await useFetch(() => `/api/
immediate: false,
});
watch(route, () => {
if(route.params.projectId && project.value !== 0)
{
showing.value = true;
@ -22,6 +23,7 @@ else
{
showing.value = false;
}
})
</script>
<template>

View File

@ -1,89 +1,26 @@
<template>
<slot
:data="data?.data"
:body="data?.body"
:toc="data?.toc"
:excerpt="data?.excerpt"
:error="error"
>
<MDCRenderer
v-if="body"
:tag="tag"
:class="props.class"
:body="body"
:data="data?.data"
:unwrap="props.unwrap"
:components="{
a: ProseA,
h1: ProseH1,
h2: ProseH2,
h3: ProseH3,
h4: ProseH4,
h5: ProseH5,
h6: ProseH6,
blockquote: ProseBlockquote,
}"
/>
</slot>
<template
v-if="model && model.length > 0">
<MarkdownRenderer :key="key" v-if="node" :node="node"></MarkdownRenderer>
</template>
</template>
<script setup lang="ts">
import ProseA from "~/components/content/prose/ProseA.vue";
import ProseH1 from "~/components/content/prose/ProseH1.vue";
import ProseH2 from "~/components/content/prose/ProseH2.vue";
import ProseH3 from "~/components/content/prose/ProseH3.vue";
import ProseH4 from "~/components/content/prose/ProseH4.vue";
import ProseH5 from "~/components/content/prose/ProseH5.vue";
import ProseH6 from "~/components/content/prose/ProseH6.vue";
import ProseBlockquote from "~/components/content/prose/ProseBlockquote.vue";
import { hash } from 'ohash'
import { useAsyncData } from 'nuxt/app'
import { watch, computed } from 'vue'
import type { Root } from 'hast';
const props = defineProps({
tag: {
type: [String, Boolean],
default: 'div'
},
content: {
type: [String, Object],
required: true
},
excerpt: {
type: Boolean,
default: false
},
class: {
type: [String, Array, Object],
default: ''
},
unwrap: {
type: [Boolean, String],
default: false
}
})
const model = defineModel<number>({
default: 0,
});
const model = defineModel<string>();
const parser = useMarkdown();
const key = computed(() => hash(props.content))
const key = computed(() => hash(model.value));
const { data, refresh, error } = await useAsyncData(key.value, async () => {
const timer = performance.now();
if (typeof props.content !== 'string') {
model.value = performance.now() - timer;
return props.content
const node = ref<Root>();
watch(model, async () => {
if(model.value && model.value)
{
node.value = parser(model.value);
}
const result = await parser(props.content);
model.value = performance.now() - timer;
return result;
})
const body = computed(() => props.excerpt ? data.value?.excerpt : data.value?.body)
watch(() => props.content, () => {
refresh()
})
}, { immediate: true });
</script>

View File

@ -0,0 +1,121 @@
<script lang="ts">
import type { Node, Text, Element, Comment, Root } from 'hast';
import { Text as HText, Comment as HComment } from 'vue';
import ProseP from '~/components/prose/ProseP.vue';
import ProseA from '~/components/prose/ProseA.vue';
import ProseBlockquote from '~/components/prose/ProseBlockquote.vue';
import ProseCode from '~/components/prose/ProseCode.vue';
import ProsePre from '~/components/prose/ProsePre.vue';
import ProseEm from '~/components/prose/ProseEm.vue';
import ProseH1 from '~/components/prose/ProseH1.vue';
import ProseH2 from '~/components/prose/ProseH2.vue';
import ProseH3 from '~/components/prose/ProseH3.vue';
import ProseH4 from '~/components/prose/ProseH4.vue';
import ProseH5 from '~/components/prose/ProseH5.vue';
import ProseH6 from '~/components/prose/ProseH6.vue';
import ProseHr from '~/components/prose/ProseHr.vue';
import ProseImg from '~/components/prose/ProseImg.vue';
import ProseUl from '~/components/prose/ProseUl.vue';
import ProseOl from '~/components/prose/ProseOl.vue';
import ProseLi from '~/components/prose/ProseLi.vue';
import ProseStrong from '~/components/prose/ProseStrong.vue';
import ProseTable from '~/components/prose/ProseTable.vue';
import ProseThead from '~/components/prose/ProseThead.vue';
import ProseTbody from '~/components/prose/ProseTbody.vue';
import ProseTd from '~/components/prose/ProseTd.vue';
import ProseTh from '~/components/prose/ProseTh.vue';
import ProseTr from '~/components/prose/ProseTr.vue';
import ProseScript from '~/components/prose/ProseScript.vue';
const proseList = {
"p": ProseP,
"a": ProseA,
"blockquote": ProseBlockquote,
"code": ProseCode,
"pre": ProsePre,
"em": ProseEm,
"h1": ProseH1,
"h2": ProseH2,
"h3": ProseH3,
"h4": ProseH4,
"h5": ProseH5,
"h6": ProseH6,
"hr": ProseHr,
"img": ProseImg,
"ul": ProseUl,
"ol": ProseOl,
"li": ProseLi,
"strong": ProseStrong,
"table": ProseTable,
"thead": ProseThead,
"tbody": ProseTbody,
"td": ProseTd,
"th": ProseTh,
"tr": ProseTr,
"script": ProseScript
};
export default defineComponent({
name: 'MarkdownRenderer',
props: {
node: {
type: Object,
required: true
},
proses: {
type: Object,
default: () => ({})
}
},
watch: {
node: {
handler: function(val, old) {
},
deep: true,
}
},
async setup(props) {
if(props.proses)
{
for(const prose of Object.keys(props.proses))
{
if(typeof props.proses[prose] === 'string')
props.proses[prose] = await resolveComponent(props.proses[prose]);
}
}
return { tags: Object.assign({}, proseList, props.proses) };
},
render(ctx: any) {
const { node, tags } = ctx;
const div = h('div', null, (node as Root).children.map(e => renderNode(e, tags)).filter(e => !!e));
return div;
}
});
function renderNode(node: Node, tags: Record<string, any>): VNode | undefined
{
if(node.type === 'text')
{
const text = node as Text;
if(text.value.length > 0 && text.value !== '\n')
return h(HText, (node as Text).value);
}
else if(node.type === 'comment')
{
const comment = node as Comment;
if(comment.value.length > 0 && comment.value !== '\n')
return h(HComment, (node as Comment).value);
}
else if(node.type === 'element')
{
const element = node as Element;
return h(tags[element.tagName], { ...element.properties, class: element.properties.className }, element.children.map(e => renderNode(e, tags)).filter(e => !!e));
}
return undefined;
}
</script>

View File

@ -43,7 +43,7 @@ if(props.node.color !== undefined)
<div v-if="node.text?.length > 0" class="markdown-embed-content node-insert-event" style="">
<div class="markdown-preview-view markdown-rendered node-insert-event show-indentation-guide allow-fold-headings allow-fold-lists">
<div class="markdown-preview-sizer markdown-preview-section">
<Markdown :content="node.text"/>
<Markdown v-model="node.text"/>
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Navigation } from '~/server/api/project/[projectId]/navigation.get';
import type { Navigation } from '~/types/api';
interface Props {
link: Navigation;

View File

@ -8,7 +8,7 @@
<div class="markdown-preview-view markdown-rendered node-insert-event hide-title">
<div class="markdown-preview-sizer markdown-preview-section" style="padding-bottom: 0px;">
<h1 v-if="page[0]?.title">{{ page[0]?.title }}</h1>
<Markdown :content="page[0].content" />
<Markdown v-model="page[0].content" />
</div>
</div>
</div>

View File

@ -0,0 +1,5 @@
<template>
<blockquote>
<slot />
</blockquote>
</template>

View File

@ -0,0 +1,3 @@
<template>
<code><slot /></code>
</template>

View File

@ -0,0 +1,5 @@
<template>
<em>
<slot />
</em>
</template>

View File

@ -1,7 +1,11 @@
<template>
<h1 :id="id"><slot></slot></h1>
<h1 :id="id">
<slot />
</h1>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>

View File

@ -1,7 +1,11 @@
<template>
<h2 :id="id"><slot></slot></h2>
<h2 :id="id">
<slot />
</h2>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>

View File

@ -1,7 +1,11 @@
<template>
<h3 :id="id"><slot></slot></h3>
<h3 :id="id">
<slot />
</h3>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>

View File

@ -1,7 +1,11 @@
<template>
<h4 :id="id"><slot></slot></h4>
<h4 :id="id">
<slot />
</h4>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>

View File

@ -1,7 +1,11 @@
<template>
<h5 :id="id"><slot></slot></h5>
<h5 :id="id">
<slot />
</h5>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>

View File

@ -1,7 +1,11 @@
<template>
<h6 :id="id"><slot></slot></h6>
<h6 :id="id">
<slot />
</h6>
</template>
<script setup lang="ts">
const props = defineProps<{ id?: string }>()
const generate = computed(() => props.id)
</script>

View File

@ -0,0 +1,3 @@
<template>
<hr>
</template>

View File

@ -0,0 +1,42 @@
<template>
<img
:src="refinedSrc"
:alt="alt"
:width="width"
:height="height"
/>
</template>
<script setup lang="ts">
import { withTrailingSlash, withLeadingSlash, joinURL } from 'ufo'
import { useRuntimeConfig, computed, resolveComponent } from '#imports'
const props = defineProps({
src: {
type: String,
default: ''
},
alt: {
type: String,
default: ''
},
width: {
type: [String, Number],
default: undefined
},
height: {
type: [String, Number],
default: undefined
}
})
const refinedSrc = computed(() => {
if (props.src?.startsWith('/') && !props.src.startsWith('//')) {
const _base = withLeadingSlash(withTrailingSlash(useRuntimeConfig().app.baseURL))
if (_base !== '/' && !props.src.startsWith(_base)) {
return joinURL(_base, props.src)
}
}
return props.src
})
</script>

View File

@ -0,0 +1,3 @@
<template>
<li><slot /></li>
</template>

View File

@ -0,0 +1,5 @@
<template>
<ol>
<slot />
</ol>
</template>

View File

@ -0,0 +1,3 @@
<template>
<p><slot /></p>
</template>

View File

@ -0,0 +1,36 @@
<template>
<pre :class="$props.class"><slot /></pre>
</template>
<script setup lang="ts">
defineProps({
code: {
type: String,
default: ''
},
language: {
type: String,
default: null
},
filename: {
type: String,
default: null
},
highlights: {
type: Array as () => number[],
default: () => []
},
meta: {
type: String,
default: null
},
class: {
type: String,
default: null
}
})
</script>
<style>
pre code .line{display:block}
</style>

View File

@ -0,0 +1,15 @@
<template>
<div v-if="isDev">
Rendering the <code>script</code> element is dangerous and is disabled by default. Consider implementing your own <code>ProseScript</code> element to have control over script rendering.
</div>
</template>
<script setup lang="ts">
defineProps({
src: {
type: String,
default: ''
}
})
const isDev = import.meta.dev
</script>

View File

@ -0,0 +1,5 @@
<template>
<strong>
<slot />
</strong>
</template>

View File

@ -0,0 +1,5 @@
<template>
<table>
<slot />
</table>
</template>

View File

@ -0,0 +1,5 @@
<template>
<tbody>
<slot />
</tbody>
</template>

View File

@ -0,0 +1,5 @@
<template>
<td>
<slot />
</td>
</template>

View File

@ -0,0 +1,5 @@
<template>
<th>
<slot />
</th>
</template>

View File

@ -0,0 +1,5 @@
<template>
<thead>
<slot />
</thead>
</template>

View File

@ -0,0 +1,5 @@
<template>
<tr>
<slot />
</tr>
</template>

View File

@ -0,0 +1,5 @@
<template>
<ul>
<slot />
</ul>
</template>

View File

@ -0,0 +1,5 @@
<template>
<a>
<slot></slot>
</a>
</template>

View File

@ -0,0 +1,12 @@
<template>
<span v-if="focused">> </span>
<blockquote ref="el" @focusin="focused = true" @focusout="focused = false">
<slot />
</blockquote>
</template>
<script setup lang="ts">
const focused = ref(false);
watch(focused, console.log);
</script>

View File

@ -0,0 +1,3 @@
<template>
<span v-show="false"># </span><h1><slot></slot></h1>
</template>

View File

@ -0,0 +1,12 @@
<template>
<span v-show="focused">## </span><h2><slot></slot></h2>
</template>
<script setup>
const props = defineProps({
focused: {
type: Boolean,
default: false
}
})
</script>

View File

@ -0,0 +1,3 @@
<template>
<span v-show="false">### </span><h3><slot></slot></h3>
</template>

View File

@ -0,0 +1,3 @@
<template>
<span v-show="false">#### </span><h4><slot></slot></h4>
</template>

View File

@ -0,0 +1,3 @@
<template>
<span v-show="false">##### </span><h5><slot></slot></h5>
</template>

View File

@ -0,0 +1,3 @@
<template>
<span v-show="false">###### </span><h6><slot></slot></h6>
</template>

View File

@ -1,24 +1,23 @@
import { createMarkdownParser } from "@nuxtjs/mdc/runtime/parser/index";
import RemarkBreaks from "remark-breaks";
import RemarkOfm from "remark-ofm";
import { unified, type Processor } from "unified";
import type { Root } from 'hast';
import RemarkParse from "remark-parse";
export default function useMarkdown(): Awaited<ReturnType<typeof createMarkdownParser>>
{
let parser: Awaited<ReturnType<typeof createMarkdownParser>>
import RemarkRehype from 'remark-rehype';
import RemarkOfm from 'remark-ofm';
import RemarkGfm from 'remark-gfm';
const parse = async (markdown: string) => {
if (!parser)
export default function useMarkdown(): (md: string) => Root
{
parser = await createMarkdownParser({
remark: {
plugins: {
'remark-breaks': { instance: RemarkBreaks },
'remark-ofm': { instance: RemarkOfm }
let processor: Processor;
const parse = (markdown: string) => {
if (!processor)
{
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkRehype]);
}
},
});
}
return parser(markdown);
const processed = processor.runSync(processor.parse(markdown)) as Root;
return processed;
}
return parse;

View File

@ -0,0 +1,40 @@
import type { UserSession, UserSessionComposable } from '~/types/auth'
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
const useAuthReadyState = () => useState('nuxt-auth-ready', () => false)
/**
* Composable to get back the user session and utils around it.
* @see https://github.com/atinux/nuxt-auth-utils
*/
export function useUserSession(): UserSessionComposable {
const sessionState = useSessionState()
const authReadyState = useAuthReadyState()
return {
ready: computed(() => authReadyState.value),
loggedIn: computed(() => Boolean(sessionState.value.user)),
user: computed(() => sessionState.value.user || null),
session: sessionState,
fetch,
clear,
}
}
async function fetch() {
const authReadyState = useAuthReadyState()
useSessionState().value = await useRequestFetch()('/api/auth/session', {
headers: {
Accept: 'text/json',
},
retry: false,
}).catch(() => ({}))
if (!authReadyState.value) {
authReadyState.value = true
}
}
async function clear() {
await $fetch('/api/auth/session', { method: 'DELETE' })
useSessionState().value = {}
useRouter().go(0);
}

BIN
db.sqlite

Binary file not shown.

22
middleware/auth.global.ts Normal file
View File

@ -0,0 +1,22 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn, ready, fetch } = useUserSession();
const meta = to.meta;
if(!ready)
await fetch();
if(!!meta.guestsGoesTo && !loggedIn.value)
{
return navigateTo(meta.guestsGoesTo);
}
else if(meta.requireAuth && !loggedIn.value)
{
return abortNavigation();
}
else if(!!meta.usersGoesTo && loggedIn.value)
{
return navigateTo(meta.usersGoesTo);
}
return;
});

View File

@ -1,11 +1,13 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: ["@nuxtjs/color-mode", "@nuxtjs/mdc"],
modules: ["@nuxtjs/color-mode", "nuxt-security"],
css: ['~/assets/common.css', '~/assets/global.css'],
runtimeConfig: {
dbFile: '',
sessionPassword: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
session: {
password: '699c46bd-9aaa-4364-ad01-510ee4fe7013'
}
},
components: [
{

View File

@ -1,17 +1,19 @@
{
"devDependencies": {
"@nuxtjs/color-mode": "^3.4.2",
"@nuxtjs/mdc": "^0.8.3",
"@types/bun": "^1.1.6",
"nuxt": "^3.12.4",
"vue": "^3.4.35",
"vue-router": "^4.4.2",
"@types/diff": "^5.2.1",
"hast-util-to-html": "^9.0.1",
"nuxt": "^3.12.4",
"nuxt-security": "^2.0.0-rc.9",
"remark-breaks": "^4.0.0",
"remark-ofm": "link:remark-ofm",
"vue": "^3.4.37",
"vue-router": "^4.4.3",
"zod": "^3.23.8"
},
"dependencies": {
"diff": "^5.2.0",
"lodash.capitalize": "^4.2.1"
}
}

View File

@ -1,18 +1,220 @@
<style>
.editor-container
{
width: 100%;
height: 100%;
overflow: auto;
display: flex;
justify-content: space-around;
}
.editor
{
width: 45%;
}
</style>
<template>
<div class="column">
<textarea v-model="input"></textarea>
<pre>{{ timing }}ms</pre>
</div>
<div class="column">
<Head>
<Title>Live Editing</Title>
</Head>
<div class="editor-container">
<Suspense>
<template #fallback>
<div class="loading"></div>
</template>
<Markdown v-if="input.length > 0" :content="input" v-model="timing"/>
<EditableMarkdown class="editor-preview" v-if="input.length > 0" v-model="input"></EditableMarkdown>
</Suspense>
<textarea class="editor" v-model="input"></textarea>
</div>
</template>
<script setup lang="ts">
const input = ref(""), timing = ref(0);
const input = ref(`## Liste de sorts provisoire
%% Equilibrage: Les sorts de dégâts plus cher ne doivent pas forcément proposer plus de dés de dégâts mais offrir plus d'options et avoir des dé de dégâts plus haut, pour synergiser avec les buffs de l'arbre de magie. %%
**Notation:**
- Nom #element (coût, durée d'incantation, portée, prérequis d'incantation)
> Effet
### Rang 1
- Trait de feu #element/feu (4 mana, tour, 12 cases, V/Ge/Gl)
>Tire un faisceau de flamme, infligeant 2d8 dégâts de [[Les types de dégâts#Feu|feu]].
- Echauffement #element/feu (2 mana, tour, V/Gl)
>Chauffe à blanc une arme ou un projectile. Jusqu'au début de votre prochain tour, les coups portés avec l'objet infligent 1d6 dégâts supplémentaire. Les dégâts de l'arme deviennent des dégâts de [[Les types de dégâts#Feu|feu]].
- #element/feu (mana, tour)
>
- Corps ardent #element/feu(6 mana, tour, V/Ge/Gl/C)
>Pendant 5 tours, toute personne terminant son tour à une case de vous subit 1d10 dégâts de [[Les types de dégâts#Feu|feu]].
- #element/feu(mana, tour)
>
- Protection supérieure #element/glace (2 mana, réaction, V/Gl)
>L'armure subit l'intégralité des dégâts sur le prochain coup.
- Lames de glace #element/glace (4 mana, tour, 12 cases)
>Tire 2 projectiles infligeant 1d8 dégâts de [[Les types de dégâts#Glace|glace]]. Augmenter les dés de dégâts offre un projectile supplémentaire à la place. Chaque projectile demande un jet d'attaque séparé et peut viser une cible différente.
- #element/glace(mana, tour)
>
- #element/glace(mana, tour)
>
- #element/glace(mana, tour)
>
- Chaine de foudre #element/foudre (4 mana, tour, 12 cases)
>Frappe une cible visible puis rebondit sur jusqu'à 2 autres cibles à 1 case de la première. Inflige 1d8 dégâts de [[Les types de dégâts#Foudre|foudre]].
- Vitesse lumière #element/foudre (3 mana, tour)
>Se téléporte à 6 cases tant que vous pouvez voir et courir vers la destination.
- Décharge de foudre #element/foudre(3 mana, tour)
>Tire une décharge foudroyante d'énergie, infligeant 4d4[[Glossaire#Jet explosif|!]] dégâts de [[Les types de dégâts#Foudre|foudre]].
- Faisceau fulgurant #element/foudre(4 mana, tour)
>Lance un faisceau électrique qui peut contourner les obstacles pour toucher une cible à couvert. Inflige 3d4[[Glossaire#Jet explosif|!]] dégâts de [[Les types de dégâts#Foudre|foudre]].
>*Vous pouvez viser une case que vous ne voyez pas, mais le MJ ne doit pas vous informer si l'attaque pourrait toucher une cible.*
- #element/foudre(mana, tour)
>
- #element/terre(2 mana, tour)
>Un pilier de matière est extirpé du sol pour aller frapper la cible, qui est alors déplacée d'une case. Si la cible est propulsée contre un mur, elle subit alors 3d12 dégâts [[Les types de dégâts#Contondant|contondant]].
- #element/terre(3 mana, tour)
>Propulse un projectile de matière sur la cible, infligeant 1d12 dégâts [[Les types de dégâts#Contondant|contondant]] et appliquant un [[Les effets#L'étourdissement|étourdissement]] (2/12).
- Bouclier tortue #element/terre(3 mana, tour)
>Vous gagnez un bonus de 2 en blocage, mais subissez également un malus de 2 en esquive et perdez 2 cases de vitesse de course durant 1 min.
- #element/terre(2 mana, réaction)
> Vous gagnez une résistance aux dégâts [[Les types de dégâts#Les dégâts physiques|physiques]] jusqu'à la fin de votre prochain tour.
- #element/terre(mana, tour)
>
- Enchantement mineur #element/arcane(2 mana, tour, V/Gl)
> Condense de l'énergie magique dans une arme ou un projectile. Vous faites une attaque immédiatement après avoir lancé ce sort sans dépenser d'action, infligeant 1d8 dégâts supplémentaire. Les dégâts de l'arme deviennent [[Les types de dégâts#Neutre|magique]].
- Rupture de force #element/arcane(3 mana, tour, V/Ge/Gl)
> Vous condensez une puissante énergie magique qui est propulsée directement sur votre cible. Vous lancez 2d20 et prenez le plus haut résultat pour infliger des dégâts [[Les types de dégâts#Neutre|magique]]. *Avoir un #avantage aux dégâts permet de lancer un autre d20.* *Augmenter les dégâts de ce sort permet d'infliger 5 dégâts [[Les types de dégâts#Neutre|magique]] supplémentaire.*
- #element/arcane(mana, tour)
>
- #element/arcane(mana, tour)
>
- #element/arcane(mana, tour)
>
- Foulée aérienne #element/air(3 mana, tour, 12 cases)
>La vitesse de course de votre cible augmente de 2 cases pendant 1 minute. Vous gagnez également un bonus de +1 à l'esquive.
- Pression forcée #element/air(5 mana, tour, 18 cases)
>Crée une imposante colonne d'air descendent de 3 cases de rayon sur 12 cases de haut. Les créatures à l'intérieur ont un malus de 1 à l'esquive. Les créatures volantes chutent de 3 cases par tour.
- #element/air(mana, tour)
>
- #element/air(mana, tour)
>
- #element/air(mana, tour)
>
- Conservation #element/nature (2 mana, 1 minute)
>Permet à jusqu'à 5 herbes ou préparations médicinales de se conserver 1 jour de plus. *Ne peux être utilisé qu'une seule fois par herbe/préparation.*
- #element/nature(mana, tour)
>
- #element/nature(mana, tour)
>
- #element/nature(mana, tour)
>
- #element/nature(mana, tour)
>
- Absorption radieuse #element/lumiere (3 mana, tour)
> Absorbe la lumière d'une zone de 4 cases de rayon, la faisant apparaitre comme plus sombre. #todo
- #element/lumiere (mana, tour)
>
- #element/lumiere (mana, tour)
>
- #element/lumiere (mana, tour)
>
- #element/lumiere (mana, tour)
>
- #element/psy(6 mana, tour)
>Envenime l'esprit de la cible, brouillant sa perception de la réalité et lui faisant voir des images subliminales de chaos. Applique un effet de [[Les effets#La peur|peur]] (4/12).
### Rang 2
- Trait de feu 2 #element/feu (5 mana, tour, 15 cases, V/Ge/Gl)
>Tire un faisceau de flamme, infligeant 3d8 de dégâts de feu.
- Lames de glace 2 #element/glace (5 mana, tour, 15 cases)
>Tire 3 projectiles à 1d8 de glace. Augmenter les dés de dégâts offre un projectile supplémentaire à la place. Chaque projectile demande un jet d'attaque séparé et peut viser une cible différente.
- Chaine de foudre 2 #element/foudre (5 mana, tour, 15 cases)
>Frappe une cible visible puis rebondit sur jusqu'à 3 autres cibles à 2 cases de la première. 1d8+3 de foudre.
- Décharge de foudre 2 #element/foudre(3 mana, tour)
>Tire une décharge foudroyante d'énergie, infligeant 6d4[[Glossaire#Jet explosif|!]] de dégâts de foudre.
- Conservation 2 #element/nature (4 mana, 1 minute)
>Permet à jusqu'à 8 herbes ou préparations médicinales de se conserver 3 jours de plus. *Ne peux être utilisé qu'une seule fois par herbe/préparation.*
- Boule de feu #element/feu (8 mana, tour, 12 cases)
>Projette une imposante boule de flamme explosant au contact d'une surface, infligeant ainsi 4d10 de feu sur 3 cases de rayon.
- Détonation #element/feu (4 mana, tour, 8 cases)
>Pointe un lieu visible. Une explosion de flamme jaillit subitement, infligeant 2d10 de feu sur 2 cases de rayon.
- Lance de givre #element/glace(4 mana, tour)
>Une lame de glace vient grandir le long de votre arme. Augmente votre portée d'une case. L'arme inflige des dégâts tranchants. Dure 1 min, casse après 8 coups réussis.
- Téléportation #element/foudre (4 mana, tour)
>Se téléporte à un point visible à 9 cases max.
- Apaisement #element/psy (3 mana, tour)
>En touchant la cible, vous pouvez faire un jet d'intelligence. Guérit l'influence, le charme et la peur, mais augmente les chances de ces effet de 1 niveau pendant 3 tours.
- Painshock #element/psy (6 mana, tour)
>*Ne fonctionne que si la cible touchée à subit des dégâts depuis votre dernier tour.* Vous touchez une plaie et intensifiez la douleur à l'extrême. Applique un effet d'[[Les effets#L'étourdissement|étourdissement]]. La difficulté est égale à 2/12 + 1 niveau pour chaque 10% de vie max retiré.
- Perturbateur #element/psy (4 mana, réaction, 9 cases, V/Ge)
>Lorsqu'un lanceur de sort termine son incantation, vous pouvez perturber les flux magiques pour lui imposer un malus de 3 au jet.
### Rang 3
- Rejet pur #divin (spécial, tour, 3 cases, Ge)
>Vous propulsez une énergie magique pure condensée sur votre adversaire avec une puissance absolue. Vous infligez 1d6!+4 dégâts [[Les types de dégâts#Neutre|magique]] tous les 3 mana dépensé. Vous pouvez dépenser jusqu'à 30 mana. Après avoir lancé ce sort, vous subissez un malus de 4 au lancer de sort pendant 1 tour.
### Sorts unique
Les sorts uniques sont des sorts obtenus uniquement avec des objets magiques ou en progressant dans l'arbre d'entrainement. Il n'existe **aucun** autre moyen d'obtenir des sorts.
- Dévastation #element/feu + #element/glace + #element/foudre (10 mana, tour, 12 cases)
>Inflige 10+3d10 dégâts. Vous pouvez choisir le type de dégâts entre feu, glace et foudre. Ignore les résistances et réduit les immunités en résistance. ^484fc3
- Soin #element/nature (8 mana, tour, toucher)
>Soigne 10+1d10 PV et guérit l'[[Les effets#L'étourdissement|étourdissement]], le [[Les effets#Le saignement|saignement]] et les [[Les effets#L'empoisonnement|poisons]]. ^068b55
- Contresort #element/arcane (4 mana, réaction, 12 cases)
>Perturbe les flux magique pour interrompre une canalisation en cours. Vous pouvez augmenter le coût du sort pour augmenter les chances de réussite. La difficulté est égale à 6 - le cout du sort à interrompre + le cout du contresort. ^a8f46f
- Focalisation destructrice #element/arcane (12 mana, tour)
>Vous focalisez les énergies magiques sur vous, rendant l'utilisation de sort plus complexe pour les autres. La densité d'énergie anormale vous fait subir 5 points de dégâts par tour. Pendant une minute, toute personne à 18 cases de vous essayant de lancer un sort ou de [[1.Règles/6.Les Aspects/index#Transformations|se transformer]] subit un malus de 4. ^73b8bd
- Domination mentale #element/psy (10 mana, tour, toucher)
>Applique un effet de [[Les effets#La possession|possession]] (6/12). ^5b38b6
### Sorts spéciaux
Les sorts spéciaux sont une liste de sorts que les joueurs peuvent obtenir durant certaines aventures. Selon les cas, un joueur peut demander au maitre du jeu de commencer avec un sort spécial si ça correspond à son passé. Les sorts spéciaux peuvent aussi être des sorts que les PNJ ont et qu'ils peuvent apprendre aux joueurs.`);
</script>

View File

@ -24,7 +24,7 @@ await set(parseInt(route.params.projectId as string));
<div class="markdown-preview-view markdown-rendered node-insert-event hide-title">
<div class="markdown-preview-sizer markdown-preview-section" style="padding-bottom: 0px;">
<h1>{{ content[0].title }}</h1>
<Markdown :content="content[0].content" />
<Markdown v-model="content[0].content" />
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
<script lang="ts">
<!--<script lang="ts">
let icon: HTMLDivElement | null;
function toggleLeftPanel(_: Event) {
icon!.parentElement!.parentElement!.parentElement!.parentElement!.classList.toggle('is-left-column-open');
@ -84,4 +84,6 @@ onMounted(() => {
</div>
</template>
</ContentList>
</template>
</template>-->
<template></template>

View File

@ -18,7 +18,7 @@ Le logo a été créé grâce aux icones de [Game Icons](https://game-icons.net)
<div class="publish-renderer">
<div class="markdown-preview-view markdown-rendered node-insert-event hide-title">
<div class="markdown-preview-sizer markdown-preview-section" style="padding-bottom: 0px;">
<Markdown :content="data" />
<Markdown v-model="data" />
</div>
</div>
</div>

View File

@ -1,46 +1,74 @@
<script setup lang="ts">
import { hydrate } from 'vue';
import { ZodError } from 'zod';
import { schema, type Login } from '~/schemas/login';
definePageMeta({
auth: {
disconnectedOnly: true,
connectedRedirect: '/user/profile'
}
usersGoesTo: '/user/profile'
});
const state = reactive<Login>({
username: '',
usernameOrEmail: '',
password: ''
});
const { status, login } = useAuth();
const { data: result, status, error, refresh } = await useFetch('/api/auth/login', {
body: state,
immediate: false,
method: 'POST',
watch: false,
ignoreResponseError: true,
})
const usernameError = ref("");
const passwordError = ref("");
const generalError = ref("");
async function submit()
{
if(state.password === "")
return;
const data = schema.safeParse(state);
if(data.success && state.password !== "")
if(data.success)
{
let errors = await login(data.data.username, data.data.password);
await refresh()
if(status.value === AuthStatus.connected)
const login = result.value;
if(!login || !login.success)
{
await navigateTo('/user/profile', { replace: true });
handleErrors(login?.error ?? error.value!);
}
else
else if(status.value === 'success' && login.success)
{
errors = errors?.issues ?? errors;
usernameError.value = errors?.find((e: any) => e.path.includes("username"))?.message ?? "";
passwordError.value = errors?.find((e: any) => e.path.includes("password"))?.message ?? "";
console.log(await navigateTo('/user/profile'));
}
}
else
{
usernameError.value = data.error?.issues.find(e => e.path.includes("username"))?.message ?? "";
passwordError.value = data.error?.issues.find(e => e.path.includes("password"))?.message ?? "";
handleErrors(data.error);
}
}
function handleErrors(error: Error | ZodError)
{
if(error.hasOwnProperty('issues'))
{
for(const err of (error as ZodError).issues)
{
if(err.path.includes('username'))
{
usernameError.value = err.message;
}
if(err.path.includes('password'))
{
passwordError.value = err.message;
}
}
}
else
{
generalError.value = error?.message ?? 'Erreur inconnue.';
}
}
</script>
@ -51,20 +79,20 @@ async function submit()
</Head>
<div class="site-body-center-column">
<div class="render-container flex align-center justify-center">
<form v-if="status === AuthStatus.disconnected" @submit.prevent="submit" class="input-form input-form-wide">
<form @submit.prevent="submit" class="input-form input-form-wide">
<h1>Connexion</h1>
<Input type="text" autocomplete="username" v-model="state.username"
<Input type="text" autocomplete="username" v-model="state.usernameOrEmail"
placeholder="" title="Nom d'utilisateur ou adresse mail" :error="usernameError" />
<Input type="password" autocomplete="current-password" v-model="state.password"
placeholder="" title="Mot de passe"
:error="passwordError" />
<button>Se connecter</button>
<span v-if="generalError" class="input-error">{{ generalError }}</span>
<button>
<div class="loading" v-if="status === 'pending'"></div>
<template v-else>Se connecter</template>
</button>
<NuxtLink :to="{ path: `/user/register`, force: true }">Pas de compte ?</NuxtLink>
</form>
<div v-else-if="status === AuthStatus.loading" class="input-form"><div class="loading"></div></div>
<div v-else class="not-found-container">
<div class="not-found-title">👀 Vous n'avez rien à faire ici. 👀</div>
</div>
</div>
</div>
</template>

View File

@ -1,13 +1,9 @@
<script setup lang="ts">
definePageMeta({
auth: {
disconnectedOnly: false,
disconnectedRedirect: '/user/login'
}
guestsGoesTo: '/user/login'
});
const { data } = useAuth();
const { user, clear } = useUserSession();
</script>
<template>
@ -18,7 +14,8 @@ const { data } = useAuth();
<div class="render-container">
<div class="not-found-container">
<ThemeIcon icon="logo" :width=128 :height=128 />
<div class="not-found-title">Work in prorgess</div>
<div class="not-found-title">Bonjour {{ user?.username }} :)</div>
<button @click="clear">Se deconnecter</button>
</div>
</div>
</div>

View File

@ -1,11 +1,9 @@
<script setup lang="ts">
import { ZodError } from 'zod';
import { schema, type Registration } from '~/schemas/registration';
definePageMeta({
auth: {
disconnectedOnly: true,
connectedRedirect: '/user/profile'
}
usersGoesTo: '/user/profile'
});
const state = reactive<Registration>({
@ -16,8 +14,6 @@ const state = reactive<Registration>({
const confirmPassword = ref("");
const { status, register } = useAuth();
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
const checkedLowerUpper = computed(() => state.password.toLowerCase() !== state.password && state.password.toUpperCase() !== state.password);
const checkedDigit = computed(() => /[0-9]/.test(state.password));
@ -25,30 +21,61 @@ const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("
const usernameError = ref("");
const emailError = ref("");
const generalError = ref("");
const { data: result, status, error, execute } = await useFetch('/api/auth/register', {
body: state,
immediate: false,
method: 'POST',
watch: false,
ignoreResponseError: true,
})
async function submit()
{
if(state.password === "" || state.password !== confirmPassword.value)
return;
const data = schema.safeParse(state);
if(data.success && state.password !== "" && confirmPassword.value === state.password)
if(data.success)
{
let errors = await register(data.data.username, data.data.email, data.data.password, {});
await execute()
if(status.value === AuthStatus.connected)
const login = result.value;
if(!login || !login.success)
{
await navigateTo('/');
handleErrors(login?.error ?? error.value!);
}
else
else if(status.value === 'success' && login.success)
{
errors = errors?.issues ?? errors;
usernameError.value = errors?.find((e: any) => e.path.includes("username"))?.message ?? "";
emailError.value = errors?.find((e: any) => e.path.includes("email"))?.message ?? "";
console.log(await navigateTo('/user/profile'));
}
}
else
{
usernameError.value = data.error?.issues.find(e => e.path.includes("username"))?.message ?? "";
emailError.value = data.error?.issues.find(e => e.path.includes("email"))?.message ?? "";
handleErrors(data.error);
}
}
function handleErrors(error: Error | ZodError)
{
if(error.hasOwnProperty('issues'))
{
for(const err of (error as ZodError).issues)
{
if(err.path.includes('username'))
{
usernameError.value = err.message;
}
if(err.path.includes('email'))
{
emailError.value = err.message;
}
}
}
else
{
generalError.value = error?.message ?? 'Erreur inconnue.';
}
}
</script>
@ -60,7 +87,7 @@ async function submit()
</Head>
<div class="site-body-center-column">
<div class="render-container flex align-center justify-center">
<form v-if="status === AuthStatus.disconnected" @submit.prevent="submit" class="input-form input-form-wide">
<form @submit.prevent="submit" class="input-form input-form-wide">
<h1>Inscription</h1>
<Input type="text" autocomplete="username" v-model="state.username"
placeholder="Entrez un nom d'utiliateur" title="Nom d'utilisateur" :error="usernameError" />
@ -85,14 +112,12 @@ async function submit()
</div>
<Input type="password" v-model="confirmPassword" placeholder="Confirmer le mot de passe"
title="Confirmer le mot de passe"
autocomplete="new-password"
:error="confirmPassword === '' || confirmPassword === state.password ? '' : 'Les mots de passe saisies ne sont pas identique'" />
<button>S'inscrire</button>
<span v-if="generalError" class="input-error">{{ generalError }}</span>
<button><div v-if="status === 'pending'" class="loading"></div><template v-else>S'inscrire</template></button>
<NuxtLink :to="{ path: `/user/login`, force: true }">Se connecter</NuxtLink>
</form>
<div v-else-if="status === AuthStatus.loading" class="input-form"><div class="loading"></div></div>
<div v-else class="not-found-container">
<div class="not-found-title">👀 Vous n'avez rien à faire ici. 👀</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,7 @@
export default defineNuxtPlugin(async (nuxtApp) => {
if (!nuxtApp.payload.serverRendered || Boolean(nuxtApp.payload.prerenderedAt) || Boolean(nuxtApp.payload.isCached)) {
nuxtApp.hook('app:mounted', async () => {
await useUserSession().fetch()
})
}
})

11
plugins/session.server.ts Normal file
View File

@ -0,0 +1,11 @@
export default defineNuxtPlugin({
name: 'session-fetch-plugin',
enforce: 'pre',
async setup(nuxtApp) {
// Flag if request is cached
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache)
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
await useUserSession().fetch()
}
},
})

View File

@ -1,7 +1,7 @@
import { z } from "zod";
export const schema = z.object({
username: z.string({ required_error: "Nom d'utilisateur obligatoire" }),
usernameOrEmail: z.string({ required_error: "Nom d'utilisateur ou email obligatoire" }),
password: z.string({ required_error: "Mot de passe obligatoire" }),
});

View File

@ -35,7 +35,17 @@ function securePassword(password: string, ctx: z.RefinementCtx): void {
}
export const schema = z.object({
username: z.string({ required_error: "Nom d'utilisateur obligatoire" }).min(3, "Votre nom d'utilisateur doit contenir au moins 3 caractères").max(32, "Votre nom d'utilisateur doit contenir au plus 32 caractères"),
username: z.string({ required_error: "Nom d'utilisateur obligatoire" }).min(3, "Votre nom d'utilisateur doit contenir au moins 3 caractères").max(32, "Votre nom d'utilisateur doit contenir au plus 32 caractères").superRefine((user, ctx) => {
const test = z.string().email().safeParse(user);
if(test.success)
{
ctx.addIssue({
code: z.ZodIssueCode.invalid_string,
validation: 'email',
message: "Votre nom d'utilisateur ne peut pas être une addresse mail",
});
}
}),
email: z.string({ required_error: "Email obligatoire" }).email("Adresse mail invalide"),
password: z.string({ required_error: "Mot de passe obligatoire" }).min(8, "Votre mot de passe doit contenir au moins 8 caractères").max(128, "Votre mot de passe doit contenir au moins 8 caractères").superRefine(securePassword),
data: z.object({

View File

@ -1,96 +1,89 @@
import useDatabase from '~/composables/useDatabase';
import { schema } from '~/schemas/login';
import { User, UserExtendedData, UserRawData, UserSession, UserSessionRequired } from '~/types/auth';
import type { Database } from "bun:sqlite";
import { ZodError } from 'zod';
import { checkSession, logSession } from '~/server/utils/user';
export default defineEventHandler(async (e) => {
const { sessionPassword } = useRuntimeConfig();
const session = await useSession(e, {
password: sessionPassword,
});
interface SuccessHandler
{
success: true;
session: UserSession;
}
interface ErrorHandler
{
success: false;
error: Error | ZodError<{
usernameOrEmail: string;
password: string;
}>;
}
type Return = SuccessHandler | ErrorHandler;
export default defineEventHandler(async (e): Promise<Return> => {
try
{
const session = await getUserSession(e);
const db = useDatabase();
console.log(session.id);
const checkedSession = await checkSession(e, session);
if(session.id && session.data.id)
{
const checkSession = db.query("SELECT user_id FROM user_sessions WHERE id = ?1");
const sessionId = checkSession.get(session.id) as any;
if(checkedSession !== undefined)
return checkedSession;
console.log(sessionId);
if(sessionId && sessionId.user_id === session.data.id)
{
return { success: true, id: session.data.id, sessionId: session.id, data: session.data };
}
else
{
session.clear();
setResponseStatus(e, 406);
return { success: false, error: { path: ['global'], message: 'Vous êtes déjà connecté' } };
}
}
const body = await readValidatedBody(e, schema.safeParse);
if (!body.success)
{
session.clear();
await clearUserSession(e);
setResponseStatus(e, 406);
return { success: false, error: body.error };
}
const hash = await Bun.password.hash(body.data.password);
const checkID = db.query(`SELECT id FROM users WHERE (username = ?1 or email = ?1)`);
const id = checkID.get(body.data.username) as any;
const checkID = db.query(`SELECT id, hash FROM users WHERE (username = ?1 or email = ?1)`);
const id = checkID.get(body.data.usernameOrEmail) as { id: number, hash: string };
if(!id || !id.id)
if(!id || !id.id || !id.hash)
{
session.clear();
await clearUserSession(e);
setResponseStatus(e, 401);
return { success: false, error: { path: ['username'], message: 'Identifiant inconnu' } };
return { success: false, error: new ZodError([{ code: 'custom', path: ['username'], message: 'Identifiant inconnu' }]) };
}
const checkHash = db.query(`SELECT COUNT(*) as count FROM users WHERE id = ?1 and hash = ?2`);
const validation = checkHash.get(id.id, hash) as any;
const valid = await Bun.password.verify(body.data.password, id.hash);
if(validation && validation.count && validation.count !== 1)
if(!valid)
{
session.clear();
await clearUserSession(e);
setResponseStatus(e, 401);
return { success: false, error: { path: ['password'], message: 'Mot de passe incorrect' } };
return { success: false, error: new ZodError([{ code: 'custom', path: ['password'], message: 'Mot de passe incorrect' }]) };
}
const loggingIn = db.query(`INSERT INTO user_sessions(id, user_id, ip, agent, lastRefresh) VALUES(?1, ?2, ?3, ?4, ?5)`);
loggingIn.get(session.id, id.id, getRequestIP(e), getRequestHeader(e, 'User-Agent'), Date.now());
await session.update(getData(db, id.id));
logSession(e, await setUserSession(e, { user: getData(db, id.id) }) as UserSessionRequired);
setResponseStatus(e, 201);
return { success: true, id: id.id, sessionId: session.id, data: session.data };
return { success: true, session };
}
catch(e)
catch(err: any)
{
session.clear();
await clearUserSession(e);
console.error(e);
return { success: false, error: e };
console.error(err);
return { success: false, error: err as Error };
}
});
function getData(db: Database, id: string): any
function getData(db: Database, id: number): User
{
const userQuery = db.query(`SELECT * FROM users WHERE id = ?1`);
const user = userQuery.get(id);
const userQuery = db.query(`SELECT id, username, email, state FROM users WHERE id = ?1`);
const user = userQuery.get(id) as UserRawData;
const userDataQuery = db.query(`SELECT * FROM users_data WHERE user_id = ?1`);
const userData = userDataQuery.get(id);
const userData = userDataQuery.get(id) as UserExtendedData;
return { ...user, ...userData };
}

View File

@ -1,2 +0,0 @@
export default defineEventHandler(async (e) => {
});

View File

@ -1,41 +1,67 @@
import { ZodError, ZodIssue } from 'zod';
import useDatabase from '~/composables/useDatabase';
import { schema } from '~/schemas/registration';
import { checkSession, logSession } from '~/server/utils/user';
import { UserSession, UserSessionRequired } from '~/types/auth';
export default defineEventHandler(async (e) => {
interface SuccessHandler
{
success: true;
session: UserSession;
}
interface ErrorHandler
{
success: false;
error: Error | ZodError<{
username: string;
email: string;
password: string;
}>;
}
type Return = SuccessHandler | ErrorHandler;
export default defineEventHandler(async (e): Promise<Return> => {
try
{
const { sessionPassword } = useRuntimeConfig();
const session = await getUserSession(e);
const db = useDatabase();
const checkedSession = await checkSession(e, session);
if(checkedSession !== undefined)
return checkedSession;
const body = await readValidatedBody(e, schema.safeParse);
if (!body.success)
{
await clearUserSession(e);
setResponseStatus(e, 406);
return { success: false, error: body.error };
}
const db = useDatabase();
const usernameQuery = db.query(`SELECT COUNT(*) as count FROM users WHERE username = ?1`);
const checkUsername = usernameQuery.get(body.data.username) as any;
const emailQuery = db.query(`SELECT COUNT(*) as count FROM users WHERE email = ?1`);
const checkEmail = emailQuery.get(body.data.email) as any;
const errors = [];
const errors: ZodIssue[] = [];
if(checkUsername.count !== 0)
errors.push({ path: ['username'], message: "Ce nom d'utilisateur est déjà utilisé" });
errors.push({ code: 'custom', path: ['username'], message: "Ce nom d'utilisateur est déjà utilisé" });
if(checkEmail.count !== 0)
errors.push({ path: ['email'], message: "Cette adresse mail est déjà utilisée" });
errors.push({ code: 'custom', path: ['email'], message: "Cette adresse mail est déjà utilisée" });
if(errors.length > 0)
{
setResponseStatus(e, 406);
return { success: false, error: errors };
return { success: false, error: new ZodError(errors) };
}
else
{
const hash = await Bun.password.hash(body.data.password);
const registration = db.query(`INSERT INTO users(username, email, hash, email_valid) VALUES(?1, ?2, ?3, 0)`);
const registration = db.query(`INSERT INTO users(username, email, hash, state) VALUES(?1, ?2, ?3, 0)`);
registration.get(body.data.username, body.data.email, hash) as any;
const userIdQuery = db.query(`SELECT id FROM users WHERE username = ?1`);
@ -44,19 +70,17 @@ export default defineEventHandler(async (e) => {
const registeringData = db.query(`INSERT INTO users_data(user_id) VALUES(?1)`);
registeringData.get(id);
const session = await useSession(e, {
password: sessionPassword,
});
const loggingIn = db.query(`INSERT INTO user_sessions(id, user_id, ip, agent, lastRefresh) VALUES(?1, ?2, ?3, ?4, ?5)`);
loggingIn.get(session.id, id, getRequestIP(e), getRequestHeader(e, 'User-Agent'), Date.now());
logSession(e, await setUserSession(e, { user: { id: id, username: body.data.username, email: body.data.email, state: 0 } }) as UserSessionRequired);
setResponseStatus(e, 201);
return { success: true, id: id, sessionId: session.id };
return { success: true, session };
}
}
catch(e)
catch(err: any)
{
return { success: false, error: e };
await clearUserSession(e);
console.error(err);
return { success: false, error: err as Error };
}
});

View File

@ -0,0 +1,8 @@
import { eventHandler } from 'h3';
import { clearUserSession } from '~/server/utils/session';
export default eventHandler(async (event) => {
await clearUserSession(event);
return { loggedOut: true };
})

View File

@ -0,0 +1,13 @@
import { eventHandler } from 'h3'
import { getUserSession, sessionHooks } from '~/server/utils/session'
import type { UserSessionRequired } from '~/types/auth'
export default eventHandler(async (event) => {
const session = await getUserSession(event)
if (session.user) {
await sessionHooks.callHookParallel('fetch', session as UserSessionRequired, event)
}
return session
})

File diff suppressed because one or more lines are too long

32
server/plugins/session.ts Normal file
View File

@ -0,0 +1,32 @@
import useDatabase from "~/composables/useDatabase";
const monthAsMs = 1000 * 60 * 60 * 24 * 30;
export default defineNitroPlugin(() => {
const db = useDatabase();
sessionHooks.hook('fetch', async (session, event) => {
const query = db.prepare('SELECT lastRefresh FROM user_sessions WHERE id = ?1 AND user_id = ?2');
const result = query.get(session.id, session.user.id) as Record<string, any>;
if(!result)
{
throw createError({ statusCode: 401, message: 'Unauthorized' });
}
else if(result && result.lastRefresh && result.lastRefresh < Date.now() - monthAsMs)
{
throw createError({ statusCode: 401, message: 'Session has expired' });
}
else
{
db.prepare('UPDATE user_sessions SET lastRefresh = ?1 WHERE id = ?2 AND user_id = ?3').run(Date.now(), session.id, session.user.id);
}
});
sessionHooks.hook('clear', async (session, event) => {
if(session.id && session.user)
{
const query = db.prepare('DELETE FROM user_sessions WHERE id = ?1 AND user_id = ?2');
query.run(session.id, session.user.id);
}
});
});

110
server/utils/session.ts Normal file
View File

@ -0,0 +1,110 @@
import type { H3Event, SessionConfig } from 'h3'
import { useSession, createError } from 'h3'
import { defu } from 'defu'
import { createHooks } from 'hookable'
import { useRuntimeConfig } from '#imports'
import type { UserSession, UserSessionRequired } from '~/types/auth'
export interface SessionHooks {
/**
* Called when fetching the session from the API
* - Add extra properties to the session
* - Throw an error if the session could not be verified (with a database for example)
*/
fetch: (session: UserSessionRequired, event: H3Event) => void | Promise<void>
/**
* Called before clearing the session
*/
clear: (session: UserSession, event: H3Event) => void | Promise<void>
}
export const sessionHooks = createHooks<SessionHooks>()
/**
* Get the user session from the current request
* @param event The Request (h3) event
* @returns The user session
*/
export async function getUserSession(event: H3Event) {
const session = await _useSession(event);
if(!session.data || !session.data.id)
{
await session.update(defu({ id: session.id }, session.data));
}
return session.data;
}
/**
* Set a user session
* @param event The Request (h3) event
* @param data User session data, please only store public information since it can be decoded with API calls
* @see https://github.com/atinux/nuxt-auth-utils
*/
export async function setUserSession(event: H3Event, data: UserSession) {
const session = await _useSession(event)
await session.update(defu(data, session.data))
return session.data
}
/**
* Replace a user session
* @param event The Request (h3) event
* @param data User session data, please only store public information since it can be decoded with API calls
*/
export async function replaceUserSession(event: H3Event, data: UserSession) {
const session = await _useSession(event)
await session.clear()
await session.update(data)
return session.data
}
/**
* Clear the user session and removing the session cookie
* @param event The Request (h3) event
* @returns true if the session was cleared
*/
export async function clearUserSession(event: H3Event) {
const session = await _useSession(event)
await sessionHooks.callHookParallel('clear', session.data, event)
await session.clear()
return true
}
/**
* Require a user session, throw a 401 error if the user is not logged in
* @param event
* @param opts Options to customize the error message and status code
* @param opts.statusCode The status code to use for the error (defaults to 401)
* @param opts.message The message to use for the error (defaults to Unauthorized)
* @see https://github.com/atinux/nuxt-auth-utils
*/
export async function requireUserSession(event: H3Event, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
const userSession = await getUserSession(event)
if (!userSession.user) {
throw createError({
statusCode: opts.statusCode || 401,
message: opts.message || 'Unauthorized',
})
}
return userSession as UserSessionRequired
}
let sessionConfig: SessionConfig
function _useSession(event: H3Event) {
if (!sessionConfig) {
const runtimeConfig = useRuntimeConfig(event)
sessionConfig = runtimeConfig.session;
}
return useSession<UserSession>(event, sessionConfig)
}

33
server/utils/user.ts Normal file
View File

@ -0,0 +1,33 @@
import useDatabase from "~/composables/useDatabase";
import { Return } from "~/types/api";
import type { UserSession, UserSessionRequired } from "~/types/auth";
export async function checkSession(e: H3Event<EventRequestHandler>, session: UserSession): Promise<Return | undefined>
{
const db = useDatabase();
if(session.id && session.user?.id)
{
const checkSession = db.query("SELECT user_id FROM user_sessions WHERE id = ?1");
const sessionId = checkSession.get(session.id) as any;
if(sessionId && sessionId.user_id === session.user?.id)
{
return { success: true, session };
}
else
{
await clearUserSession(e);
setResponseStatus(e, 406);
return { success: false, error: new Error('Vous êtes déjà connecté') };
}
}
}
export async function logSession(e: H3Event<EventRequestHandler>, session: UserSessionRequired)
{
const db = useDatabase();
const loggingIn = db.query(`INSERT INTO user_sessions(id, user_id, ip, agent, lastRefresh) VALUES(?1, ?2, ?3, ?4, ?5)`);
loggingIn.get(session.id, session.user.id, getRequestIP(e) ?? null, getRequestHeader(e, 'User-Agent') ?? null, Date.now());
}

View File

@ -1,3 +1,19 @@
export interface SuccessHandler
{
success: true;
session: UserSession;
}
export interface ErrorHandler
{
success: false;
error: Error | ZodError<{
username: string;
email: string;
password: string;
}>;
}
export type Return = SuccessHandler | ErrorHandler;
export interface Project {
id: number;
name: string;

71
types/auth.d.ts vendored Normal file
View File

@ -0,0 +1,71 @@
import type { ComputedRef, Ref } from 'vue'
import 'vue-router';
declare module 'vue-router'
{
interface RouteMeta
{
requiresAuth?: boolean;
guestsGoesTo?: string;
usersGoesTo?: string;
}
}
import 'nuxt';
declare module 'nuxt'
{
interface RuntimeConfig
{
session: SessionConfig;
}
}
export interface UserRawData {
id: number;
username: string;
email: string;
state: number;
}
export interface UserExtendedData {
}
export type User = UserRawData & UserExtendedData;
export interface UserSession {
user?: User;
id?: string;
}
export interface UserSessionRequired extends UserSession {
user: User;
id: string;
}
export interface UserSessionComposable {
/**
* Computed indicating if the auth session is ready
*/
ready: ComputedRef<boolean>
/**
* Computed indicating if the user is logged in.
*/
loggedIn: ComputedRef<boolean>
/**
* The user object if logged in, null otherwise.
*/
user: ComputedRef<User | null>
/**
* The session object.
*/
session: Ref<UserSession>
/**
* Fetch the user session from the server.
*/
fetch: () => Promise<void>
/**
* Clear the user session and remove the session cookie.
*/
clear: () => Promise<void>
}

View File