Sort members list #45

Merged
twig merged 11 commits from sort-members-list into main 2025-07-18 05:34:44 +00:00
20 changed files with 231 additions and 34 deletions

View file

@ -23,3 +23,17 @@ steps:
when: when:
- branch: main - branch: main
event: push event: push
- name: container-build-and-publish (staging)
image: docker
commands:
- docker login --username radical --password $PASSWORD git.gorb.app
- docker buildx build --platform linux/amd64,linux/arm64 --rm --push -t git.gorb.app/gorb/frontend:staging .
environment:
PASSWORD:
from_secret: docker_password
volumes:
- /var/run/podman/podman.sock:/var/run/docker.sock
when:
- branch: staging
event: push

View file

@ -26,9 +26,7 @@
<script lang="ts" setup> <script lang="ts" setup>
const { fetchFriends } = useApi(); const { fetchFriends } = useApi();
const friends = await fetchFriends().then((response) => { const friends = sortUsers(await fetchFriends())
return response.sort((a, b) => getDisplayName(a).localeCompare(getDisplayName(b)))
})
const props = defineProps<{ const props = defineProps<{
variant: string variant: string

View file

@ -21,8 +21,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { UserResponse } from '~/types/interfaces'; import type { UserResponse } from '~/types/interfaces';
const { fetchMembers } = useApi();
const props = defineProps<{ const props = defineProps<{
user: UserResponse user: UserResponse
}>(); }>();

View file

@ -1,14 +1,14 @@
<template> <template>
<div @click="props.callback()" class="button" :class="props.variant + '-button'"> <button @click="props.callback ? props.callback() : null" class="button" :class="props.variant + '-button'">
{{ props.text }} {{ props.text }}
</div> </button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const props = defineProps<{ const props = defineProps<{
text: string, text: string,
callback: CallableFunction, callback?: CallableFunction,
variant?: "normal" | "scary" | "neutral", variant?: "normal" | "scary" | "neutral",
}>(); }>();
@ -28,6 +28,8 @@ const props = defineProps<{
border-radius: 0.7rem; border-radius: 0.7rem;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
border: none;
} }
.button:hover { .button:hover {

View file

@ -1,24 +1,36 @@
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse, StatsResponse, UserResponse } from "~/types/interfaces"; import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse, StatsResponse, UserResponse } from "~/types/interfaces";
function ensureIsArray(list: any) {
if (Array.isArray(list)) {
return list
} else {
return []
}
}
export const useApi = () => { export const useApi = () => {
async function fetchGuilds(): Promise<GuildResponse[] | undefined> { async function fetchGuilds(): Promise<GuildResponse[]> {
return await fetchWithApi(`/guilds`); return ensureIsArray(await fetchWithApi(`/guilds`));
} }
async function fetchGuild(guildId: string): Promise<GuildResponse | undefined> { async function fetchGuild(guildId: string): Promise<GuildResponse | undefined> {
return await fetchWithApi(`/guilds/${guildId}`); return await fetchWithApi(`/guilds/${guildId}`);
} }
async function fetchChannels(guildId: string): Promise<ChannelResponse[] | undefined> { async function fetchMyGuilds(): Promise<GuildResponse[]> {
return await fetchWithApi(`/guilds/${guildId}/channels`); return ensureIsArray(await fetchWithApi(`/me/guilds`));
}
async function fetchChannels(guildId: string): Promise<ChannelResponse[]> {
return ensureIsArray(await fetchWithApi(`/guilds/${guildId}/channels`));
} }
async function fetchChannel(channelId: string): Promise<ChannelResponse | undefined> { async function fetchChannel(channelId: string): Promise<ChannelResponse | undefined> {
return await fetchWithApi(`/channels/${channelId}`) return await fetchWithApi(`/channels/${channelId}`)
} }
async function fetchMembers(guildId: string): Promise<GuildMemberResponse[] | undefined> { async function fetchMembers(guildId: string): Promise<GuildMemberResponse[]> {
return await fetchWithApi(`/guilds/${guildId}/members`); return ensureIsArray(await fetchWithApi(`/guilds/${guildId}/members`));
} }
async function fetchMember(guildId: string, memberId: string): Promise<GuildMemberResponse | undefined> { async function fetchMember(guildId: string, memberId: string): Promise<GuildMemberResponse | undefined> {
@ -34,12 +46,7 @@ export const useApi = () => {
} }
async function fetchFriends(): Promise<UserResponse[]> { async function fetchFriends(): Promise<UserResponse[]> {
const response = await fetchWithApi('/me/friends') return ensureIsArray(await fetchWithApi('/me/friends'));
if (Array.isArray(response)) {
return response
} else {
return []
}
} }
async function addFriend(username: string): Promise<void> { async function addFriend(username: string): Promise<void> {
@ -79,9 +86,18 @@ export const useApi = () => {
await fetchWithApi("/auth/verify-email", { method: "POST", body: { email } }); await fetchWithApi("/auth/verify-email", { method: "POST", body: { email } });
} }
async function sendPasswordResetEmail(identifier: string): Promise<void> {
await fetchWithApi("/auth/reset-password", { method: "GET", query: { identifier } });
}
async function resetPassword(password: string, token: string) {
await fetchWithApi("/auth/reset-password", { method: "POST", body: { password, token } });
}
return { return {
fetchGuilds, fetchGuilds,
fetchGuild, fetchGuild,
fetchMyGuilds,
fetchChannels, fetchChannels,
fetchChannel, fetchChannel,
fetchMembers, fetchMembers,
@ -97,6 +113,8 @@ export const useApi = () => {
joinGuild, joinGuild,
createChannel, createChannel,
fetchInstanceStats, fetchInstanceStats,
sendVerificationEmail sendVerificationEmail,
sendPasswordResetEmail,
resetPassword
} }
} }

View file

@ -18,6 +18,7 @@
</div> </div>
<div v-else id="auth-form-container"> <div v-else id="auth-form-container">
<slot /> <slot />
<div v-if="!['/recover', '/reset-password'].includes(route.path)">Forgot password? Recover <NuxtLink href="/recover">here</NuxtLink>!</div>
</div> </div>
<div v-if="instanceUrl"> <div v-if="instanceUrl">
Instance URL is set to <span style="color: var(--primary-color);">{{ instanceUrl }}</span> Instance URL is set to <span style="color: var(--primary-color);">{{ instanceUrl }}</span>
@ -36,7 +37,11 @@ const apiVersion = useRuntimeConfig().public.apiVersion;
const apiBase = useCookie("api_base"); const apiBase = useCookie("api_base");
const registrationEnabled = useState("registrationEnabled", () => true); const registrationEnabled = useState("registrationEnabled", () => true);
const auth = useAuth(); const route = useRoute();
const query = route.query as Record<string, string>;
const searchParams = new URLSearchParams(query);
searchParams.delete("token");
onMounted(async () => { onMounted(async () => {
instanceUrl.value = useCookie("instance_url").value; instanceUrl.value = useCookie("instance_url").value;
@ -111,6 +116,7 @@ const form = reactive({
#auth-form-container form { #auth-form-container form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
text-align: left; text-align: left;
margin-top: 10dvh; margin-top: 10dvh;
gap: 1em; gap: 1em;

View file

@ -95,7 +95,7 @@ const options = [
if (invite.length == 6) { if (invite.length == 6) {
try { try {
const joinedGuild = await api.joinGuild(invite); const joinedGuild = await api.joinGuild(invite);
guilds?.push(joinedGuild); guilds.push(joinedGuild);
return await navigateTo(`/servers/${joinedGuild.uuid}`); return await navigateTo(`/servers/${joinedGuild.uuid}`);
} catch (error) { } catch (error) {
alert(`Couldn't use invite: ${error}`); alert(`Couldn't use invite: ${error}`);
@ -151,7 +151,7 @@ const options = [
} }
]; ];
const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds"); const guilds = await api.fetchMyGuilds();
function createDropdown() { function createDropdown() {
const dropdown = h(GuildDropdown, { options }); const dropdown = h(GuildDropdown, { options });

View file

@ -16,7 +16,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
} }
} }
if (["/login", "/register"].includes(to.path) && !Object.keys(to.query).includes("special")) { if (["/login", "/register", "/recover", "/reset-password"].includes(to.path) && !Object.keys(to.query).includes("special")) {
console.log("path is login or register"); console.log("path is login or register");
const apiBase = useCookie("api_base"); const apiBase = useCookie("api_base");
console.log("apiBase gotten:", apiBase.value); console.log("apiBase gotten:", apiBase.value);

View file

@ -5,10 +5,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
const guildId = to.params.serverId as string; const guildId = to.params.serverId as string;
const channels: ChannelResponse[] | undefined = await fetchChannels(guildId); const channels: ChannelResponse[] = await fetchChannels(guildId);
console.log("channels:", channels); console.log("channels:", channels);
if (channels && channels.length > 0) { if (channels.length > 0) {
console.log("wah"); console.log("wah");
return await navigateTo(`/servers/${guildId}/channels/${channels[0].uuid}`, { replace: true }); return await navigateTo(`/servers/${guildId}/channels/${channels[0].uuid}`, { replace: true });
} }

View file

@ -36,6 +36,7 @@ const form = reactive({
const query = useRoute().query as Record<string, string>; const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query); const searchParams = new URLSearchParams(query);
searchParams.delete("token");
const registrationEnabled = ref<boolean>(true); const registrationEnabled = ref<boolean>(true);
const apiBase = useCookie("api_base"); const apiBase = useCookie("api_base");
@ -48,7 +49,7 @@ if (apiBase.value) {
} }
} }
const registerUrl = `/register?${searchParams}` const registerUrl = `/register?${searchParams}`;
const { login } = useAuth(); const { login } = useAuth();

89
pages/recover.vue Normal file
View file

@ -0,0 +1,89 @@
<template>
<NuxtLayout name="auth">
<div v-if="errorValue">{{ errorValue }}</div>
<form v-if="!emailFormSent" @submit.prevent="sendEmail">
<div>
<label for="identifier">Email or username</label>
<br>
<input type="text" name="identifier" id="identifier" v-model="emailForm.identifier">
</div>
<div>
<Button type="submit" text="Send email" variant="normal" />
</div>
</form>
<div v-else>
If an account with that username or email exists, an email will be sent to it shortly.
</div>
<div v-if="registrationEnabled">
Don't have an account? <NuxtLink :href="registerUrl">Register</NuxtLink> one!
</div>
<div>
Already have an account? <NuxtLink :href="loginUrl">Log in</NuxtLink>!
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import Button from '~/components/UserInterface/Button.vue';
const emailForm = reactive({
identifier: ""
});
const emailFormSent = ref(false);
const passwordForm = reactive({
password: ""
});
const errorValue = ref<string>();
const registrationEnabled = ref<boolean>(true);
const apiBase = useCookie("api_base");
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const token = ref(searchParams.get("token"))
searchParams.delete("token");
const { resetPassword } = useApi();
const registerUrl = `/register?${searchParams}`;
const loginUrl = `/login?${searchParams}`;
if (apiBase.value) {
console.log("apiBase:", apiBase.value);
const stats = await useApi().fetchInstanceStats(apiBase.value);
if (stats) {
registrationEnabled.value = stats.registration_enabled;
}
}
const { sendPasswordResetEmail } = useApi();
async function sendEmail() {
try {
await sendPasswordResetEmail(emailForm.identifier);
emailFormSent.value = true;
} catch (error) {
errorValue.value = (error as any).toString();
}
}
async function sendPassword() {
try {
console.log("pass:", passwordForm.password);
const hashedPass = await hashPassword(passwordForm.password)
console.log("hashed pass:", hashedPass);
await resetPassword(hashedPass, token.value!);
return await navigateTo(`/login?${searchParams}`);
} catch (error) {
errorValue.value = (error as any).toString();
}
}
</script>
<style>
</style>

View file

@ -86,6 +86,7 @@ const auth = useAuth();
const loggedIn = ref(await auth.getUser()); const loggedIn = ref(await auth.getUser());
const query = new URLSearchParams(useRoute().query as Record<string, string>); const query = new URLSearchParams(useRoute().query as Record<string, string>);
query.delete("token");
const user = await useAuth().getUser(); const user = await useAuth().getUser();

56
pages/reset-password.vue Normal file
View file

@ -0,0 +1,56 @@
<template>
<NuxtLayout name="auth">
<div v-if="errorValue">{{ errorValue }}</div>
<form @submit.prevent="sendPassword">
<div>
<label for="password">Password</label>
<br>
<input type="password" name="password" id="password" v-model="passwordForm.password">
</div>
<div>
<Button type="submit" text="Submit" variant="normal" />
</div>
</form>
<div>
Already have an account? <NuxtLink :href="loginUrl">Log in</NuxtLink>!
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import Button from '~/components/UserInterface/Button.vue';
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const loginUrl = `/login?${searchParams}`;
const token = ref(searchParams.get("token"))
if (!token.value) await navigateTo("/login");
const passwordForm = reactive({
password: ""
});
const errorValue = ref<string>();
const { resetPassword } = useApi();
async function sendPassword() {
try {
console.log("pass:", passwordForm.password);
const hashedPass = await hashPassword(passwordForm.password)
console.log("hashed pass:", hashedPass);
await resetPassword(hashedPass, token.value!);
return await navigateTo("/login?");
} catch (error) {
errorValue.value = (error as any).toString();
}
}
</script>
<style>
</style>

View file

@ -64,7 +64,7 @@ onActivated(async () => {
}); });
async function setArrayVariables() { async function setArrayVariables() {
members.value = await fetchMembers(route.params.serverId as string); members.value = sortMembers(await fetchMembers(route.params.serverId as string))
const guildUrl = `guilds/${route.params.serverId}`; const guildUrl = `guilds/${route.params.serverId}`;
channels.value = await fetchWithApi(`${guildUrl}/channels`); channels.value = await fetchWithApi(`${guildUrl}/channels`);
console.log("channels:", channels.value); console.log("channels:", channels.value);

View file

@ -1,6 +1,6 @@
import type { GuildMemberResponse, UserResponse } from "~/types/interfaces"; import type { GuildMemberResponse, UserResponse } from "~/types/interfaces";
export function getDisplayName(user: UserResponse, member?: GuildMemberResponse): string { export default (user: UserResponse, member?: GuildMemberResponse): string => {
if (member?.nickname) return member.nickname if (member?.nickname) return member.nickname
if (user.display_name) return user.display_name if (user.display_name) return user.display_name
return user.username return user.username

View file

@ -1,4 +1,4 @@
export async function hashPassword(password: string) { export default async (password: string) => {
const encodedPass = new TextEncoder().encode(password); const encodedPass = new TextEncoder().encode(password);
const hashBuffer = await crypto.subtle.digest("SHA-384", encodedPass); const hashBuffer = await crypto.subtle.digest("SHA-384", encodedPass);
const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashArray = Array.from(new Uint8Array(hashBuffer));

7
utils/sortMembers.ts Normal file
View file

@ -0,0 +1,7 @@
import type { GuildMemberResponse } from "~/types/interfaces";
export default (members: GuildMemberResponse[]): GuildMemberResponse[] => {
twig marked this conversation as resolved Outdated

No need to name function here, name is derived from file name

No need to name function here, name is derived from file name
return members.sort((a, b) => {
return getDisplayName(a.user, a).localeCompare(getDisplayName(b.user, b))
})
}

7
utils/sortUsers.ts Normal file
View file

@ -0,0 +1,7 @@
import type { UserResponse } from "~/types/interfaces";
export default (users: UserResponse[]): UserResponse[] => {
twig marked this conversation as resolved Outdated

No need to name function here, name is derived from file name

No need to name function here, name is derived from file name
return users.sort((a, b) => {
return getDisplayName(a).localeCompare(getDisplayName(b))
})
}

View file

@ -0,0 +1,3 @@
export default (username: string) => {
return /^[\w.-]+$/.test(username);
}

View file

@ -1,3 +0,0 @@
export function validateUsername(username: string) {
return /^[\w.-]+$/.test(username);
}