diff --git a/.eslintrc.js b/.eslintrc.js index b9b5429..1d3cb3d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,5 +27,6 @@ module.exports = { 'no-var': 'error', 'no-plusplus': 'off', 'prettier/prettier': ['error', { endOfLine: 'auto', semi: false }], + 'import/prefer-default-export': 'off', }, } diff --git a/README.md b/README.md index c0cc3f0..7f649e2 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,14 @@ Run Cypress locally, using the current development containers: Run Cypress in container, create brand-new containers along with it: - docker compose -f docker-compose.testing.yml run cypress + ./docker-scripts.sh test + + # or with building + + ./docker-scripts.sh test --build # tearing off the containers - docker compose -f docker-compose.testing.yml down + ./docker-scripts.sh down ### Deployment diff --git a/app/Dockerfile b/app/Dockerfile index eafe05a..df72388 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -5,6 +5,7 @@ COPY package.json yarn.lock ./ COPY app/package.json ./app/package.json COPY packages/file-graph/package.json ./packages/file-graph/package.json COPY packages/utils/package.json ./packages/utils/package.json +COPY packages/constants/package.json ./packages/constants/package.json RUN yarn install --frozen-lockfile # Dev stage @@ -16,6 +17,7 @@ CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ] FROM base as build COPY packages/file-graph ./packages/file-graph COPY packages/utils ./packages/utils +COPY packages/constants ./packages/constants COPY app ./app RUN yarn build diff --git a/app/index.html b/app/index.html index 0ff4559..8388c4b 100644 --- a/app/index.html +++ b/app/index.html @@ -5,12 +5,6 @@ Vite + Vue -
diff --git a/app/package.json b/app/package.json index 9746862..7152e58 100644 --- a/app/package.json +++ b/app/package.json @@ -11,15 +11,17 @@ "preview": "vite preview" }, "dependencies": { - "@linux-odyssey/file-graph": "*", - "@linux-odyssey/utils": "*", "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-brands-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/vue-fontawesome": "^3.0.3", + "@linux-odyssey/file-graph": "*", + "@linux-odyssey/utils": "*", + "@linux-odyssey/constants": "*", "axios": "^1.4.0", "socket.io-client": "^4.7.1", + "validator": "^13.11.0", "vue": "^3.3.4", "vue-router": "^4.2.4", "xterm": "^5.2.1", diff --git a/app/src/components/AuthForm.vue b/app/src/components/AuthForm.vue index d8c2088..7438907 100644 --- a/app/src/components/AuthForm.vue +++ b/app/src/components/AuthForm.vue @@ -128,6 +128,7 @@ const handleChange = () => { class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary" :placeholder="type === 'login' ? 'Email / Username' : 'Username'" v-model="username" + required @input="handleChange()" autocomplete="username" /> @@ -147,6 +148,7 @@ const handleChange = () => { class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary" placeholder="Email" v-model="email" + required @input="handleChange()" autocomplete="email" /> @@ -158,6 +160,7 @@ const handleChange = () => { class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary" placeholder="Password" v-model="password" + required @input="handleChange()" /> diff --git a/app/src/components/ControlPalette.vue b/app/src/components/ControlPalette.vue index bc14d7d..d8ad208 100644 --- a/app/src/components/ControlPalette.vue +++ b/app/src/components/ControlPalette.vue @@ -30,13 +30,9 @@ diff --git a/app/src/components/TerminalPart.vue b/app/src/components/TerminalPart.vue index 3453db1..1bf86d8 100644 --- a/app/src/components/TerminalPart.vue +++ b/app/src/components/TerminalPart.vue @@ -10,12 +10,12 @@ diff --git a/app/src/utils/auth.js b/app/src/utils/auth.js index 8e6a478..4094066 100644 --- a/app/src/utils/auth.js +++ b/app/src/utils/auth.js @@ -1,31 +1,83 @@ import api from './api' +import { + TooManyRequestsError, + UnauthorizedError, + ValidationError, +} from './errors' + export async function isLoggedIn() { const res = await api.get('/auth/check-session') const { loggedIn } = res.data return loggedIn } -export const login = async (username, password) => { - try { - await api.post('/auth/login', { username, password }) - return true - } catch (err) { - if (err.response?.status === 401) { - return false - } - throw err +function handleRequestError(err) { + if (!err.response) throw err // Not an axios error + const { status } = err.response + if (status === 429) { + throw new TooManyRequestsError(err.response.data.message) } + if (status === 401) { + throw new UnauthorizedError(err.response.data.message) + } + if (status === 400) { + const { errors } = err.response.data + if (Array.isArray(errors) && errors.length > 0) { + const { path, msg } = errors[0] + switch (path) { + case 'username': + throw new ValidationError('Username is already taken') + case 'email': + throw new ValidationError('Email is already taken') + case 'password': + throw new ValidationError('Password is too weak') + default: + throw new ValidationError(msg) + } + } + throw new ValidationError(err.response.data.message) + } + throw err +} + +export const login = (username, password) => { + return api + .post('/auth/login', { username, password }) + .then(() => true) + .catch(handleRequestError) } export const register = (username, email, password) => { - return api.post('/auth/register', { - username, - password, - email, - }) + return api + .post('/auth/register', { + username, + password, + email, + }) + .then(() => true) + .catch(handleRequestError) +} + +export const checkUsername = async (username) => { + return api + .get('/auth/check-username', { + params: { username }, + }) + .then(() => true) + .catch(handleRequestError) +} + +export const chooseUsername = async (username) => { + return api + .post('/auth/register-from-session', { username }) + .then(() => true) + .catch(handleRequestError) } export const logout = () => { - return api.post('/auth/logout') + return api + .post('/auth/logout') + .then(() => true) + .catch(handleRequestError) } diff --git a/app/src/utils/errors.js b/app/src/utils/errors.js new file mode 100644 index 0000000..2fe0cbb --- /dev/null +++ b/app/src/utils/errors.js @@ -0,0 +1,27 @@ +/* eslint-disable max-classes-per-file */ +export class TooManyRequestsError extends Error { + constructor(message) { + super(message) + this.name = 'TooManyRequestsError' + } +} + +export class ValidationError extends Error { + constructor(message) { + super(message) + this.name = 'ValidationError' + } +} + +export class UnauthorizedError extends Error { + constructor(message) { + super(message) + this.name = 'UnauthorizedError' + } +} + +export default { + TooManyRequestsError, + ValidationError, + UnauthorizedError, +} diff --git a/app/src/utils/session.js b/app/src/utils/session.js index e659bff..bbf0f3b 100644 --- a/app/src/utils/session.js +++ b/app/src/utils/session.js @@ -1,6 +1,8 @@ import { ref } from 'vue' import { FileGraph } from '@linux-odyssey/file-graph' import api from './api' +import Socket from './socket' +import SocketTerminal from './terminal' class SessionManager { constructor(questId) { @@ -16,17 +18,44 @@ class SessionManager { ) this.pwd = ref('') this.status = ref('inactive') + this.socket = new Socket() + this.term = new SocketTerminal(40, 80) + } + + init() { + this.socket.on('terminal', (data) => { + this.term.write(data) + }) + this.term.onData((data) => { + this.socket.emit('terminal', data) + }) + this.socket.on('graph', (event) => { + this.handleGraphUpdate(event) + }) + this.socket.on('hints', (event) => { + this.handleHintUpdate(event) + }) + this.socket.on('tasks', (tasks) => { + this.setTasks(tasks) + }) + this.socket.on('status', (event) => { + this.handleStatusUpdate(event) + }) + return this.getActiveSession() } getSession() { return this.session.value } - setSession(session) { + async setSession(session) { this.session.value = session this.graph.value = new FileGraph(session.graph) this.hints.value = session.hints this.status.value = session.status + this.term.reset() + await this.socket.connect(session) + this.term.focus() } handleGraphUpdate(event) { @@ -58,33 +87,18 @@ class SessionManager { this.setSession(data) } - async getSessionList() { - const res = await api.get('/sessions', { - params: { quest_id: this.questId }, + async getActiveSession() { + const res = await api.post('/sessions/active', { + quest_id: this.questId, }) - const { data } = res - return data - } - - async lastSession() { - const sessions = await this.getSessionList() - if (sessions.length === 0) { - return + const session = res.data + console.log(session) + try { + await this.setSession(session) + } catch (err) { + console.log(err) + this.createSession().catch(console.error) } - const { _id: sessionId } = sessions[sessions.length - 1] - const { data: session } = await api.get(`/sessions/${sessionId}`) - console.log( - `Last Session ID: ${session._id}, Quest: ${session.quest}, Created At: ${session.createdAt}` - ) - this.setSession(session) - } - - async lastOrCreate() { - await this.lastSession() - if (!this.session.value) { - await this.createSession() - } - console.log(this.session.value) } } diff --git a/app/src/utils/socket.js b/app/src/utils/socket.js index 69050ca..54fbb49 100644 --- a/app/src/utils/socket.js +++ b/app/src/utils/socket.js @@ -7,22 +7,28 @@ class SocketWrapper { } connect(session) { - if (this.socket) { - this.socket.off() - this.socket.disconnect() - } + return new Promise((resolve, reject) => { + if (this.socket) { + this.socket.off() + this.socket.disconnect() + } - this.socket = io('', { - query: { - session_id: session._id, - }, + this.socket = io('', { + query: { + session_id: session._id, + }, + }) + + this.socket.on('connect', () => { + console.log('Socket connected to session', session._id) + }) + + this.socket.once('terminal', () => { + resolve() + }) + this.socket.once('error', reject) + this.bindListeners() }) - - this.socket.on('connect', () => { - console.log('Socket connected to session', session._id) - }) - - this.bindListeners() } on(event, callback) { @@ -32,6 +38,12 @@ class SocketWrapper { } } + once(event, callback) { + if (this.socket) { + this.socket.once(event, callback) + } + } + bindListeners() { this.listeners.forEach(({ event, callback }) => { this.socket.on(event, callback) @@ -47,4 +59,4 @@ class SocketWrapper { } } -export default new SocketWrapper() +export default SocketWrapper diff --git a/app/src/utils/terminal.js b/app/src/utils/terminal.js index be6c381..528dd8d 100644 --- a/app/src/utils/terminal.js +++ b/app/src/utils/terminal.js @@ -1,6 +1,5 @@ import { Terminal } from 'xterm' import { FitAddon } from 'xterm-addon-fit' -import socket from './socket' import 'xterm/css/xterm.css' class SocketTerminal { @@ -24,13 +23,18 @@ class SocketTerminal { this.fitAddon = new FitAddon() this.term.loadAddon(this.fitAddon) this.fitAddon.fit() - this.socket = socket - this.socket.on('terminal', (message) => { - this.term.write(message) - }) - this.term.onData((key) => { - this.socket.emit('terminal', key) - }) + } + + write(message) { + this.term.write(message) + } + + onData(callback) { + this.term.onData(callback) + } + + focus() { + this.term.focus() } resizeScreen() { @@ -43,9 +47,9 @@ class SocketTerminal { this.resizeScreen() } - clear() { - this.term.clear() + reset() { + this.term.reset() } } -export default new SocketTerminal(40, 100) +export default SocketTerminal diff --git a/app/src/views/ChooseUsernamePage.vue b/app/src/views/ChooseUsernamePage.vue index decc2b4..082aae9 100644 --- a/app/src/views/ChooseUsernamePage.vue +++ b/app/src/views/ChooseUsernamePage.vue @@ -2,44 +2,46 @@ import { isValidUsername } from '@linux-odyssey/utils' import AuthForm from '../components/AuthForm.vue' import Background from '../components/DynamicBackground.vue' +import { TooManyRequestsError, ValidationError } from '../utils/errors' +import { checkUsername, chooseUsername } from '../utils/auth' -import api from '../utils/api' - -async function checkUsername({ username, error }) { - if (username && !isValidUsername(username)) { - error('Invalid username.') - return - } - if (username.length > 3) { - try { - const { data } = await api.get('/auth/check-username', { - params: { username }, - }) - - const { available } = data - if (!available) { - error('Username is already taken.') - } - } catch { +async function check({ username, error }) { + if (username) { + if (!isValidUsername(username)) { error('Invalid username.') + return + } + try { + await checkUsername(username) + } catch (err) { + if (err instanceof TooManyRequestsError) { + error('Too many requests. Try again in 2 minutes.') + return + } + if (err instanceof ValidationError) { + error(err.message) + return + } + error('Something went wrong. Please try again later.') } } } -function handleSubmit({ username, success, error }) { - api - .post('/auth/register-from-session', { - username, - }) - .then(success) - .catch((err) => { - if (err.response?.status === 409) { - error('Username is already taken.') - } else { - console.error(err.message) - error('Something went wrong.') - } - }) +async function handleSubmit({ username, success, error }) { + try { + await chooseUsername(username) + success() + } catch (err) { + if (err instanceof TooManyRequestsError) { + error('Too many requests. Try again in 2 minutes.') + return + } + if (err instanceof ValidationError) { + error(err.message) + return + } + error(err.message) + } }