nidus-sync/ts/view/configuration/UserEdit.vue
Eli Ribble f88ca57d97
Migrate existing ts types from the API into the API module
This makes it possible to start hydrating the types into valid data
types like Dates which means I can get type safety guarantees when
displaying information.
2026-04-09 00:25:21 +00:00

470 lines
11 KiB
Vue

<style scoped>
.avatar-preview {
width: 120px;
height: 120px;
object-fit: cover;
border: 3px solid #dee2e6;
}
.bi-avatar {
height: 128px;
width: 128px;
}
.btn-close-white {
opacity: 0.8;
}
.btn-close-white:hover {
opacity: 1;
}
.card {
border-radius: 0.5rem;
}
.badge {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
display: inline-flex;
align-items: center;
}
pre {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
}
</style>
<template>
<div class="container mt-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">User Configuration</h4>
</div>
<div v-if="userChanges" class="card-body">
<!-- Avatar Section -->
<div class="row mb-4">
<div class="col-md-12">
<label class="form-label fw-bold">Avatar</label>
<div class="d-flex align-items-center">
<div class="position-relative">
<i
v-if="
userChanges.avatar == null || userChanges.avatar == ''
"
class="bi bi-avatar"
></i>
<img
v-else
:src="userChanges.avatar"
alt="User Avatar"
class="rounded-circle avatar-preview"
/>
<button
class="btn btn-sm btn-danger position-absolute bottom-0 start-0 rounded-circle"
@click="removeAvatar"
type="button"
>
<i class="bi bi-trash"></i>
</button>
<button
class="btn btn-sm btn-primary position-absolute bottom-0 end-0 rounded-circle"
@click="triggerFileInput"
type="button"
>
<i class="bi bi-camera"></i>
</button>
</div>
<div class="ms-3">
<input
ref="fileInput"
type="file"
class="d-none"
accept="image/*"
@change="handleAvatarChange"
/>
</div>
</div>
</div>
</div>
<!-- Display Name -->
<div class="row mb-3">
<div class="col-md-6">
<label for="displayName" class="form-label fw-bold">
Display Name
</label>
<input
id="display_name"
v-model="userChanges.display_name"
type="text"
class="form-control"
placeholder="Enter display name"
/>
</div>
<!-- Username (Read-only) -->
<div class="col-md-6">
<label for="username" class="form-label fw-bold">
Username
</label>
<input
id="username"
v-model="userChanges.username"
type="text"
class="form-control"
readonly
disabled
/>
</div>
</div>
<!-- User Role -->
<div class="row mb-3">
<div class="col-md-6">
<label for="userRole" class="form-label fw-bold">
User Role
</label>
<select
v-if="userChanges && userChanges.role == 'root'"
disabled
id="role"
class="form-select"
>
<option value="root">Root</option>
</select>
<select
id="role"
v-else
v-model="userChanges.role"
class="form-select"
>
<option value="">Select a role</option>
<option
v-for="option in optionRoles"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<!-- User Status -->
<div class="col-md-6">
<label class="form-label fw-bold">User Status</label>
<div class="btn-group w-100" role="group">
<input
type="checkbox"
class="btn-check"
id="isActive"
v-model="userChanges.is_active"
value="active"
/>
<label class="btn btn-outline-success" for="isActive">
<i class="bi bi-check-circle"></i> Active
</label>
</div>
</div>
</div>
<!-- User Tags -->
<div class="row mb-4">
<div class="col-md-12">
<label class="form-label fw-bold">User Tags</label>
<div class="mb-2">
<span
v-for="tag in userChanges.tags"
:key="tag"
class="badge bg-info text-dark me-2 mb-2"
>
{{ tag }}
<button
type="button"
class="btn-close btn-close-white ms-2"
@click="removeTag(tag)"
style="font-size: 0.6rem"
></button>
</span>
<span
v-if="userChanges.tags.length === 0"
class="text-muted fst-italic"
>
No tags added
</span>
</div>
<div class="input-group">
<select v-model="selectedTag" class="form-select">
<option value="">Select a tag</option>
<option
v-for="tag in availableTags"
:key="tag"
:value="tag"
:disabled="userChanges.tags.includes(tag)"
>
{{ tag }}
</option>
</select>
<button
class="btn btn-outline-primary"
type="button"
@click="addTag"
:disabled="
!selectedTag || userChanges.tags.includes(selectedTag)
"
>
<i class="bi bi-plus-lg"></i> Add Tag
</button>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="row">
<div class="col-md-12">
<hr />
<div class="d-flex justify-content-end">
<button
class="btn btn-secondary me-2"
:disabled="isSaving"
type="button"
@click="cancelChanges"
>
Cancel
</button>
<button
class="btn btn-primary"
:disabled="isSaving"
type="button"
@click="saveChanges"
>
<i class="bi bi-save"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
<div v-else>
<p>loading</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, toRaw } from "vue";
import { useSessionStore } from "@/store/session";
import { useUserStore } from "@/store/user";
import { User } from "@/type/api";
interface Props {
id: number;
}
interface Option {
label: string;
value: string;
}
export interface UserChanges {
avatar: string;
display_name: string;
is_active: boolean;
role: string;
tags: string[];
username: string;
}
const fileInput = ref<HTMLInputElement | null>(null);
const isSaving = ref<boolean>(false);
const props = defineProps<Props>();
const selectedFile = ref<File | null>(null);
const selectedTag = ref<string>("");
const userStore = useUserStore();
const session = useSessionStore();
const userChanges = ref<UserChanges>();
const optionRoles: Option[] = [
{ value: "account-owner", label: "Account Owner" },
{ value: "manager", label: "Manager" },
{ value: "tech1", label: "Tech 1" },
{ value: "tech2", label: "Tech 2" },
{ value: "tech3", label: "Tech 3" },
];
const availableTags: string[] = ["warrant", "drone pilot"];
const triggerFileInput = () => {
fileInput.value?.click();
};
const handleAvatarChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
selectedFile.value = file;
const reader = new FileReader();
reader.onload = (e) => {
if (userChanges.value == undefined) {
console.log("can't update userChanges, it's undefined");
return;
}
userChanges.value.avatar = e.target?.result as string;
};
reader.readAsDataURL(file);
}
};
const removeAvatar = () => {
if (userChanges.value == undefined) {
return;
}
userChanges.value.avatar = "";
if (fileInput.value) {
fileInput.value.value = "";
}
};
const addTag = () => {
if (userChanges.value == null) {
return;
}
if (
selectedTag.value &&
!userChanges.value.tags.includes(selectedTag.value)
) {
userChanges.value.tags.push(selectedTag.value);
selectedTag.value = "";
}
};
const removeTag = (tag: string) => {
if (userChanges.value == null) {
return;
}
const index = userChanges.value.tags.indexOf(tag);
if (index > -1) {
userChanges.value.tags.splice(index, 1);
}
};
interface UserRequestPut {
avatar?: string | null;
display_name?: string;
is_active?: boolean;
role?: string;
tags?: string[];
}
const saveChanges = async () => {
const uc = userChanges.value;
if (!uc) {
console.log("empty user changes");
return;
}
console.log("Saving user changes");
isSaving.value = true;
const all_users = await userStore.withAll();
const u = all_users.find((u: User) => u.id == props.id);
if (!u) {
console.log("no matching user");
isSaving.value = false;
return;
}
let payload: UserRequestPut = {};
if (uc.avatar != u.avatar) {
if (selectedFile.value) {
try {
const formData = new FormData();
formData.append("file", selectedFile.value);
const url = session.urls?.api.avatar;
if (!url) {
console.log("empty avatar url");
return;
}
const response = await fetch(url, {
body: formData,
method: "POST",
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const data = await response.json();
payload.avatar = data.uri;
} catch (error) {
console.error("Failed to upload avatar", error);
isSaving.value = false;
return;
}
} else if (!uc.avatar) {
payload.avatar = null;
}
}
if (uc.display_name != u.display_name) {
payload.display_name = uc.display_name;
}
if (uc.is_active != u.is_active) {
payload.is_active = uc.is_active;
}
if (uc.role != u.role) {
payload.role = uc.role;
}
if (uc.tags != u.tags) {
payload.tags = uc.tags;
}
if (Object.keys(payload).length === 0) {
console.log("refusing to make empty changes");
isSaving.value = false;
return;
}
const url = u.uri;
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
isSaving.value = false;
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`,
);
}
};
const cancelChanges = () => {
// Implement cancel/reset logic here
console.log("Canceling changes");
if (
confirm(
"Are you sure you want to cancel? All unsaved changes will be lost.",
)
) {
// Reset to original values or navigate away
window.history.back();
}
};
onMounted(() => {
userStore.withAll().then((users) => {
console.log("got users. looking for match", users, props.id);
for (const u of users) {
if (u.id == props.id) {
userChanges.value = {
avatar: u.avatar,
display_name: u.display_name,
is_active: u.is_active,
role: u.role,
tags: structuredClone(toRaw(u.tags)),
username: u.username,
};
console.log("User set to", u);
}
}
});
});
</script>