From d42e8c890b1d15db0494467d600e783b40afa8cf Mon Sep 17 00:00:00 2001 From: Valentin Date: Sun, 25 Sep 2022 15:22:35 +0200 Subject: [PATCH] Start CLI client --- cli/deps.ts | 9 ++++ cli/mod.ts | 19 +++++++ cli/types/api.ts | 28 ++++++++++ cli/ui/auth.ts | 61 ++++++++++++++++++++++ cli/ui/class.ts | 124 ++++++++++++++++++++++++++++++++++++++++++++ cli/ui/group.ts | 79 ++++++++++++++++++++++++++++ cli/ui/main-menu.ts | 15 ++++++ cli/ui/utils.ts | 13 +++++ 8 files changed, 348 insertions(+) create mode 100644 cli/deps.ts create mode 100644 cli/mod.ts create mode 100644 cli/types/api.ts create mode 100644 cli/ui/auth.ts create mode 100644 cli/ui/class.ts create mode 100644 cli/ui/group.ts create mode 100644 cli/ui/main-menu.ts create mode 100644 cli/ui/utils.ts diff --git a/cli/deps.ts b/cli/deps.ts new file mode 100644 index 0000000..28423a9 --- /dev/null +++ b/cli/deps.ts @@ -0,0 +1,9 @@ +export { Command } from "https://deno.land/x/cliffy@v0.25.1/command/mod.ts"; +export { + Confirm, + Input, + Secret, + Select, +} from "https://deno.land/x/cliffy@v0.25.1/prompt/mod.ts"; +export type { SelectOptionSettings } from "https://deno.land/x/cliffy@v0.25.1/prompt/select.ts"; +export { colors } from "https://deno.land/x/cliffy@v0.25.0/ansi/colors.ts"; diff --git a/cli/mod.ts b/cli/mod.ts new file mode 100644 index 0000000..6aa1896 --- /dev/null +++ b/cli/mod.ts @@ -0,0 +1,19 @@ +import { IAuthResponse } from "./types/api.ts"; +import { Auth } from "./ui/auth.ts"; +import { MainMenu } from "./ui/main-menu.ts"; +import { clear } from "./ui/utils.ts"; + +export let jwt = ""; +export let user = {}; + +async function main() { + clear(); + + const data = await Auth() as IAuthResponse; + jwt = data.jwt; + user = data.user; + + await MainMenu.show(); +} + +await main(); diff --git a/cli/types/api.ts b/cli/types/api.ts new file mode 100644 index 0000000..2e325b6 --- /dev/null +++ b/cli/types/api.ts @@ -0,0 +1,28 @@ +export interface IAuthPayload { + email: string; + password: string; +} + +export interface IAuthResponse { + jwt: string; + user: IUser; +} + +export interface IUser { + id: number; +} + +export interface IGroup { + id?: number; + name?: string; + owner?: number; +} + +export interface IUserGroup { + id?: number; + confirmed?: boolean; + createdAt?: string; + updatedAt?: string; + blocked?: boolean; + group?: IGroup; +} diff --git a/cli/ui/auth.ts b/cli/ui/auth.ts new file mode 100644 index 0000000..1e65f14 --- /dev/null +++ b/cli/ui/auth.ts @@ -0,0 +1,61 @@ +import { colors, Input, Secret } from "../deps.ts"; +import { clear } from "./utils.ts"; +import { IAuthPayload, IAuthResponse } from "../types/api.ts"; + +let email: string, + password: string; + +email = "pronocup1@yopmail.com"; +password = "Valentin74!"; + +async function authenticate({ email, password }: IAuthPayload) { + const res = await fetch("http://localhost:1337/api/auth/local", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ identifier: email, password }), + }); + + const resJson = await res.json(); + + if (resJson.error) { + const err = resJson.error; + return { + data: null, + error: colors.bold.red(`${err.name} (${err.status}): ${err.message}`), + }; + } + + return { + data: { jwt: resJson.jwt as string, user: resJson.user } as IAuthResponse, + error: false, + }; +} + +export async function Auth( + errorMessage?: string, +): Promise { + clear(errorMessage); + + const emailInput = Input; + emailInput.inject(email); + email = await emailInput.prompt({ + message: "📧 Enter your email", + minLength: 1, + }); + + password = password ?? await Secret.prompt({ + message: "🔒 Enter you password", + minLength: 1, + }); + + const { data, error } = await authenticate({ email, password }); + + if (error && typeof error === "string") { + password = ""; + return await Auth(error); + } + + return data as IAuthResponse; +} diff --git a/cli/ui/class.ts b/cli/ui/class.ts new file mode 100644 index 0000000..89aa4ca --- /dev/null +++ b/cli/ui/class.ts @@ -0,0 +1,124 @@ +import { Select, SelectOptionSettings } from "../deps.ts"; +import { jwt } from "../mod.ts"; + +interface ICliViewOptions { + title?: string; + parent?: CliView; + prefetch?: IPrefetch; + options: (arg0: IOptionsOptions) => Promise; + handleValue?: (value: string) => Promise; +} + +type CliViewParams = Record; + +interface IOptionsOptions { + params?: CliViewParams; + data: Record; +} + +interface IPrefetch { + url: string | ((params: CliViewParams) => string); + method?: string; + body?: Record; +} + +export class CliView { + title; + parent; + prefetch; + options; + handleValue; + params = {}; + + constructor(options: ICliViewOptions) { + this.title = options.title; + this.parent = options?.parent; + this.prefetch = options?.prefetch; + this.options = options.options; + this.handleValue = options?.handleValue; + } + + async show(params?: CliViewParams) { + this.params = params ?? {}; + + this.clear(); + + const rawResponse = await this.fetchData(params); + + const options = await this.options({ params, data: rawResponse }); + + const value = await Select.prompt({ + message: this.getTitle(), + options: [ + ...(options || []), + ...( + this.parent + ? [ + Select.separator("——————————"), + { name: "⬅️ Go back", value: "back" }, + ] + : [] + ), + ], + }); + + if (value === "back") { + if (this.parent) { + await this.parent?.show(this.parent?.params); + } + } + + if (typeof this.handleValue === "function") { + await this.handleValue(value); + } + } + + async fetchData(params?: CliViewParams) { + let rawResponse; + + if (this.prefetch) { + const url = typeof this.prefetch.url === "function" + ? this.prefetch.url({ ...params }) + : this.prefetch.url; + + const res = await fetch(url, { + method: this.prefetch.method ?? "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${jwt}`, + }, + body: typeof this.prefetch.body === "object" + ? JSON.stringify({ ...this.prefetch.body }) + : undefined, + }); + + rawResponse = await res.json(); + } + + return rawResponse; + } + + getTitle() { + let title = ""; + + if (this.parent) { + title += this.parent.getTitle() + " 〉"; + } + + return title + this.title; + } + + clear(messages?: string | string[]) { + console.log("\x1Bc"); + + if (messages) { + if (typeof messages === "string") { + messages = [messages]; + } + + messages.forEach((msg) => { + console.error(msg); + }); + } + } +} diff --git a/cli/ui/group.ts b/cli/ui/group.ts new file mode 100644 index 0000000..4e19e6a --- /dev/null +++ b/cli/ui/group.ts @@ -0,0 +1,79 @@ +import { Confirm, Input, Select, SelectOptionSettings } from "../deps.ts"; +import { IGroup, IUserGroup } from "../types/api.ts"; +import { clear } from "./utils.ts"; +import { jwt } from "../mod.ts"; +import { CliView } from "./class.ts"; + +export const GroupMain = new CliView({ + title: "Groups", + prefetch: { + url: "http://localhost:1337/api/user-groups", + }, + options: async ({ data }) => + await [ + // @ts-ignore Data comming from api + ...data.data.map((ug: IUserGroup) => ({ + name: `- ${ug?.group?.name}`, + value: `${ug?.group?.id}`, + })), + Select.separator("——————————"), + { name: "🔍 Join a Group", value: "search", disabled: true }, + { name: "➕ Create new Group", value: "new" }, + ], + handleValue: async (value) => { + if (typeof +value === "number" && !isNaN(+value)) { + await GroupDetails.show({ groupId: +value }); + } + }, +}); + +export const GroupDetails = new CliView({ + title: "Users", + parent: GroupMain, + prefetch: { + url: (params) => { + return typeof params?.groupId === "number" + ? `http://localhost:1337/api/groups/${+params.groupId}` + : ""; + }, + }, + options: async ({ data }) => { + // @ts-ignore Data comming from api + const allUserGroups = data?.attributes["user-groups"]; + if (!Array.isArray(allUserGroups)) return await GroupMain.show(); + return allUserGroups.map((ug) => ({ + name: ug?.user?.username, + value: "" + ug?.user?.id, + } as SelectOptionSettings)); + }, +}); + +export async function GroupForm(_group?: IGroup) { + clear(); + + const name = await Input.prompt({ + message: "Enter group name", + minLength: 1, + }); + + const confirmed = await Confirm.prompt( + `Are you sure you want to create group ${name}?`, + ); + + if (confirmed) { + const res = await fetch("http://localhost:1337/api/groups", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${jwt}`, + }, + body: JSON.stringify({ data: { name } }), + }); + + const resJson = await res.json(); + + if (typeof resJson?.data?.id === "number") { + await GroupMain.show(); + } + } +} diff --git a/cli/ui/main-menu.ts b/cli/ui/main-menu.ts new file mode 100644 index 0000000..d2bdcc8 --- /dev/null +++ b/cli/ui/main-menu.ts @@ -0,0 +1,15 @@ +import { CliView } from "./class.ts"; +import { GroupMain } from "./group.ts"; + +export const MainMenu = new CliView({ + title: "Pronocup", + options: async () => + await [ + { name: "👥 Groups", value: "group", disabled: false }, + { name: "⚽ Predictions", value: "group", disabled: false }, + ], + handleValue: async (value) => { + const views = { group: GroupMain } as Record; + if (Object.keys(views).includes(value)) return await views[value].show(); + }, +}); diff --git a/cli/ui/utils.ts b/cli/ui/utils.ts new file mode 100644 index 0000000..129d6e3 --- /dev/null +++ b/cli/ui/utils.ts @@ -0,0 +1,13 @@ +export function clear(messages?: string | string[]) { + console.log("\x1Bc"); + + if (messages) { + if (typeof messages === "string") { + messages = [messages]; + } + + messages.forEach((msg) => { + console.error(msg); + }); + } +}