refactor(frontend): 🚧 Add group/user CRUD support for admins

This commit is contained in:
hay-kot 2021-08-06 16:28:12 -08:00
parent 917177da5b
commit 695d7e96ae
46 changed files with 2015 additions and 102 deletions

View file

@ -21,5 +21,11 @@ module.exports = {
"vue/multiline-html-element-content-newline": "off",
"vue/no-mutating-props": "off",
"vue/no-v-for-template-key-on-child": "off",
"vue/valid-v-slot": [
"error",
{
allowModifiers: true,
},
],
},
};

View file

@ -5,7 +5,7 @@ export interface CrudAPIInterface {
// Route Properties / Methods
baseRoute: string;
itemRoute(itemId: string): string;
itemRoute(itemId: string | number): string;
// Methods
}
@ -21,6 +21,10 @@ export const crudMixins = <T>(
});
}
async function createOne(payload: T) {
return await requests.post<T>(baseRoute, payload);
}
async function getOne(itemId: string) {
return await requests.get<T>(itemRoute(itemId));
}
@ -37,14 +41,14 @@ export const crudMixins = <T>(
return await requests.delete<T>(itemRoute(itemId));
}
return { getAll, getOne, updateOne, patchOne, deleteOne };
return { getAll, getOne, updateOne, patchOne, deleteOne, createOne };
};
export abstract class BaseAPIClass<T> implements CrudAPIInterface {
export abstract class BaseAPIClass<T, U> implements CrudAPIInterface {
requests: ApiRequestInstance;
abstract baseRoute: string;
abstract itemRoute(itemId: string): string;
abstract itemRoute(itemId: string | number): string;
constructor(requests: ApiRequestInstance) {
this.requests = requests;
@ -56,6 +60,10 @@ export abstract class BaseAPIClass<T> implements CrudAPIInterface {
});
}
async createOne(payload: U) {
return await this.requests.post<T>(this.baseRoute, payload);
}
async getOne(itemId: string) {
return await this.requests.get<T>(this.itemRoute(itemId));
}
@ -68,7 +76,7 @@ export abstract class BaseAPIClass<T> implements CrudAPIInterface {
return await this.requests.patch(this.itemRoute(itemId), payload);
}
async deleteOne(itemId: string) {
async deleteOne(itemId: string | number) {
return await this.requests.delete<T>(this.itemRoute(itemId));
}
}

View file

@ -0,0 +1,26 @@
import { requests } from "../requests";
import { BaseAPIClass } from "./_base";
import { GroupInDB } from "~/types/api-types/user";
const prefix = "/api";
const routes = {
groups: `${prefix}/groups`,
groupsSelf: `${prefix}/groups/self`,
groupsId: (id: string | number) => `${prefix}/groups/${id}`,
};
export interface CreateGroup {
name: string;
}
export class GroupAPI extends BaseAPIClass<GroupInDB, CreateGroup> {
baseRoute = routes.groups;
itemRoute = routes.groupsId;
/** Returns the Group Data for the Current User
*/
async getCurrentUserGroup() {
return await requests.get(routes.groupsSelf);
}
}

View file

@ -1,13 +1,12 @@
import { BaseAPIClass, crudMixins } from "./_base";
import { BaseAPIClass } from "./_base";
import { Recipe } from "~/types/api-types/admin";
import { ApiRequestInstance } from "~/types/api";
import { CreateRecipe } from "~/types/api-types/recipe";
const prefix = "/api";
const routes = {
recipesCreate: `${prefix}/recipes/create`,
recipesBase: `${prefix}/recipes`,
recipesSummary: `${prefix}/recipes/summary`,
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
recipesCreateUrl: `${prefix}/recipes/create-url`,
recipesCreateFromZip: `${prefix}/recipes/create-from-zip`,
@ -19,25 +18,10 @@ const routes = {
recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`,
};
export class RecipeAPI extends BaseAPIClass<Recipe> {
baseRoute: string = routes.recipesSummary;
export class RecipeAPI extends BaseAPIClass<Recipe, CreateRecipe> {
baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug;
constructor(requests: ApiRequestInstance) {
super(requests);
const { getAll, getOne, updateOne, patchOne, deleteOne } = crudMixins<Recipe>(
requests,
routes.recipesSummary,
routes.recipesRecipeSlug
);
this.getAll = getAll;
this.getOne = getOne;
this.updateOne = updateOne;
this.patchOne = patchOne;
this.deleteOne = deleteOne;
}
async getAllByCategory(categories: string[]) {
return await this.requests.get<Recipe[]>(routes.recipesCategory, {
categories,
@ -56,10 +40,6 @@ export class RecipeAPI extends BaseAPIClass<Recipe> {
return this.requests.post(routes.recipesRecipeSlugImage(slug), { url });
}
async createOne(name: string) {
return await this.requests.post<Recipe>(routes.recipesBase, { name });
}
async createOneByUrl(url: string) {
return await this.requests.post(routes.recipesCreateUrl, { url });
}

View file

@ -1,5 +1,18 @@
import { BaseAPIClass } from "./_base";
import { UserOut } from "~/types/api-types/user";
import { UserIn, UserOut } from "~/types/api-types/user";
// Interfaces
interface ChangePassword {
currentPassword: string;
newPassword: string;
}
interface CreateAPIToken {
name: string;
}
// Code
const prefix = "/api";
@ -13,19 +26,45 @@ const routes = {
usersIdPassword: (id: string) => `${prefix}/users/${id}/password`,
usersIdFavorites: (id: string) => `${prefix}/users/${id}/favorites`,
usersIdFavoritesSlug: (id: string, slug: string) => `${prefix}/users/${id}/favorites/${slug}`,
usersApiTokens: `${prefix}/users/api-tokens`,
usersApiTokensTokenId: (token_id: string) => `${prefix}/users/api-tokens/${token_id}`,
};
export class UserApi extends BaseAPIClass<UserOut> {
baseRoute: string = routes.users;
itemRoute = (itemid: string) => routes.usersId(itemid);
export class UserApi extends BaseAPIClass<UserOut, UserIn> {
baseRoute: string = routes.users;
itemRoute = (itemid: string) => routes.usersId(itemid);
async addFavorite(id: string, slug: string) {
const response = await this.requests.post(routes.usersIdFavoritesSlug(id, slug), {});
return response.data;
}
async addFavorite(id: string, slug: string) {
return await this.requests.post(routes.usersIdFavoritesSlug(id, slug), {});
}
async removeFavorite(id: string, slug: string) {
const response = await this.requests.delete(routes.usersIdFavoritesSlug(id, slug));
return response.data;
}
async removeFavorite(id: string, slug: string) {
return await this.requests.delete(routes.usersIdFavoritesSlug(id, slug));
}
async getFavorites(id: string) {
await this.requests.get(routes.usersIdFavorites(id));
}
async changePassword(id: string, changePassword: ChangePassword) {
return await this.requests.put(routes.usersIdPassword(id), changePassword);
}
async resetPassword(id: string) {
return await this.requests.post(routes.usersIdResetPassword(id), {});
}
async createAPIToken(tokenName: CreateAPIToken) {
return await this.requests.post(routes.usersApiTokens, tokenName);
}
async deleteApiToken(tokenId: string) {
return await this.requests.delete(routes.usersApiTokensTokenId(tokenId));
}
userProfileImage(id: string) {
if (!id || id === undefined) return;
return `/api/users/${id}/image`;
}
}

View file

@ -1,11 +1,13 @@
import { RecipeAPI } from "./class-interfaces/recipes";
import { UserApi } from "./class-interfaces/users";
import { GroupAPI } from "./class-interfaces/groups";
import { ApiRequestInstance } from "~/types/api";
class Api {
private static instance: Api;
public recipes: RecipeAPI;
public users: UserApi;
public groups: GroupAPI;
constructor(requests: ApiRequestInstance) {
if (Api.instance instanceof Api) {
@ -14,6 +16,7 @@ class Api {
this.recipes = new RecipeAPI(requests);
this.users = new UserApi(requests);
this.groups = new GroupAPI(requests);
Object.freeze(this);
Api.instance = this;

View file

@ -0,0 +1,148 @@
<template>
<div>
<BaseDialog
:title="$t('settings.backup.create-heading')"
:title-icon="$globals.icons.database"
:submit-text="$t('general.create')"
:loading="loading"
@submit="createBackup"
>
<template #open="{ open }">
<v-btn class="mx-2" small :color="color" @click="open">
<v-icon left> {{ $globals.icons.create }} </v-icon> {{ $t("general.custom") }}
</v-btn>
</template>
<v-card-text class="mt-6">
<v-text-field v-model="tag" dense :label="$t('settings.backup.backup-tag')"></v-text-field>
</v-card-text>
<v-card-actions class="mt-n9 flex-wrap">
<v-switch v-model="fullBackup" :label="switchLabel"></v-switch>
<v-spacer></v-spacer>
</v-card-actions>
<v-expand-transition>
<div v-if="!fullBackup">
<v-card-text class="mt-n4">
<v-row>
<v-col sm="4">
<p>{{ $t("general.options") }}</p>
<AdminBackupImportOptions class="mt-5" @update-options="updateOptions" />
</v-col>
<v-col>
<p>{{ $t("general.templates") }}</p>
<v-checkbox
v-for="template in availableTemplates"
:key="template"
class="mb-n4 mt-n3"
dense
:label="template"
@click="appendTemplate(template)"
></v-checkbox>
</v-col>
</v-row>
</v-card-text>
</div>
</v-expand-transition>
</BaseDialog>
</div>
</template>
<script>
import { api } from "@/api";
import AdminBackupImportOptions from "./AdminBackupImportOptions";
export default {
components: {
BaseDialog,
AdminBackupImportOptions,
},
props: {
color: {
type: String,
default: "primary",
},
},
data() {
return {
tag: null,
fullBackup: true,
loading: false,
options: {
recipes: true,
settings: true,
themes: true,
pages: true,
users: true,
groups: true,
},
availableTemplates: [],
selectedTemplates: [],
};
},
computed: {
switchLabel() {
if (this.fullBackup) {
return this.$t("settings.backup.full-backup");
} else return this.$t("settings.backup.partial-backup");
},
},
created() {
this.resetData();
this.getAvailableBackups();
},
methods: {
resetData() {
this.tag = null;
this.fullBackup = true;
this.loading = false;
this.options = {
recipes: true,
settings: true,
themes: true,
pages: true,
users: true,
groups: true,
notifications: true,
};
this.availableTemplates = [];
this.selectedTemplates = [];
},
updateOptions(options) {
this.options = options;
},
async getAvailableBackups() {
const response = await api.backups.requestAvailable();
response.templates.forEach((element) => {
this.availableTemplates.push(element);
});
},
async createBackup() {
this.loading = true;
const data = {
tag: this.tag,
options: {
recipes: this.options.recipes,
settings: this.options.settings,
pages: this.options.pages,
themes: this.options.themes,
users: this.options.users,
groups: this.options.groups,
notifications: this.options.notifications,
},
templates: this.selectedTemplates,
};
if (await api.backups.create(data)) {
this.$emit("created");
}
this.loading = false;
},
appendTemplate(templateName) {
if (this.selectedTemplates.includes(templateName)) {
const index = this.selectedTemplates.indexOf(templateName);
if (index !== -1) {
this.selectedTemplates.splice(index, 1);
}
} else this.selectedTemplates.push(templateName);
},
},
};
</script>

View file

@ -0,0 +1,118 @@
<template>
<div class="text-center">
<BaseDialog
ref="baseDialog"
:title="name"
:title-icon="$globals.icons.database"
:submit-text="$t('general.import')"
:loading="loading"
@submit="raiseEvent"
>
<v-card-subtitle v-if="date" class="mb-n3 mt-3"> {{ $d(new Date(date), "medium") }} </v-card-subtitle>
<v-divider></v-divider>
<v-card-text>
<AdminBackupImportOptions class="mt-5 mb-2" @update-options="updateOptions" />
<v-divider></v-divider>
<v-checkbox
v-model="forceImport"
dense
:label="$t('settings.remove-existing-entries-matching-imported-entries')"
></v-checkbox>
</v-card-text>
<v-divider></v-divider>
<template #extra-buttons>
<!-- <TheDownloadBtn :download-url="downloadUrl">
<template #default="{ downloadFile }">
<v-btn class="mr-1" color="info" @click="downloadFile">
<v-icon left> {{ $globals.icons.download }}</v-icon>
{{ $t("general.download") }}
</v-btn>
</template>
</TheDownloadBtn> -->
</template>
</BaseDialog>
</div>
</template>
<script>
import { api } from "@/api";
import AdminBackupImportOptions from "./AdminBackupImportOptions";
const IMPORT_EVENT = "import";
export default {
components: { AdminBackupImportOptions },
props: {
name: {
type: String,
default: "Backup Name",
},
date: {
type: String,
default: "Backup Date",
},
},
data() {
return {
loading: false,
options: {
recipes: true,
settings: true,
themes: true,
users: true,
groups: true,
},
dialog: false,
forceImport: false,
rebaseImport: false,
downloading: false,
};
},
// computed: {
// downloadUrl() {
// return API_ROUTES.backupsFileNameDownload(this.name);
// },
// },
methods: {
updateOptions(options) {
this.options = options;
},
open() {
this.dialog = true;
this.$refs.baseDialog.open();
},
close() {
this.dialog = false;
},
async raiseEvent() {
const eventData = {
name: this.name,
force: this.forceImport,
rebase: this.rebaseImport,
recipes: this.options.recipes,
settings: this.options.settings,
themes: this.options.themes,
users: this.options.users,
groups: this.options.groups,
notifications: this.options.notifications,
};
this.loading = true;
const importData = await this.importBackup(eventData);
this.$emit(IMPORT_EVENT, importData);
this.loading = false;
},
async importBackup(data) {
this.loading = true;
const response = await api.backups.import(data.name, data);
if (response) {
return response.data;
}
},
},
};
</script>
<style></style>

View file

@ -0,0 +1,69 @@
<template>
<div>
<v-checkbox
v-for="(option, index) in options"
:key="index"
v-model="option.value"
class="mb-n4 mt-n3"
dense
:label="option.text"
@change="emitValue()"
></v-checkbox>
</div>
</template>
<script>
const UPDATE_EVENT = "update-options";
export default {
data() {
return {
options: {
recipes: {
value: true,
text: this.$t("general.recipes"),
},
settings: {
value: true,
text: this.$t("general.settings"),
},
pages: {
value: true,
text: this.$t("settings.pages"),
},
themes: {
value: true,
text: this.$t("general.themes"),
},
users: {
value: true,
text: this.$t("user.users"),
},
groups: {
value: true,
text: this.$t("group.groups"),
},
notifications: {
value: true,
text: this.$t("events.notification"),
},
},
};
},
mounted() {
this.emitValue();
},
methods: {
emitValue() {
this.$emit(UPDATE_EVENT, {
recipes: this.options.recipes.value,
settings: this.options.settings.value,
themes: this.options.themes.value,
pages: this.options.pages.value,
users: this.options.users.value,
groups: this.options.groups.value,
notifications: this.options.notifications.value,
});
},
},
};
</script>

View file

@ -0,0 +1,110 @@
<template>
<div>
<ImportSummaryDialog ref="report" />
<AdminBackupImportDialog
ref="import_dialog"
:name="selectedName"
:date="selectedDate"
@import="importBackup"
@delete="deleteBackup"
/>
<BaseDialog
ref="deleteBackupConfirm"
:title="$t('settings.backup.delete-backup')"
:message="$t('general.confirm-delete-generic')"
color="error"
:icon="$globals.icons.alertCircle"
@confirm="emitDelete()"
/>
<BaseStatCard :icon="$globals.icons.backupRestore" :color="color">
<template #after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("settings.backup-and-exports") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ total }}</small>
</h3>
</div>
</template>
<div class="d-flex row py-3 justify-end">
<AppButtonUpload url="/api/backups/upload" @uploaded="getAvailableBackups">
<template #default="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" class="mx-2" small color="info" @click="onButtonClick">
<v-icon left> {{ $globals.icons.upload }} </v-icon> {{ $t("general.upload") }}
</v-btn>
</template>
</AppButtonUpload>
<AdminBackupDialog :color="color" />
<v-btn :loading="loading" class="mx-2" small color="success" @click="createBackup">
<v-icon left> {{ $globals.icons.create }} </v-icon> {{ $t("general.create") }}
</v-btn>
</div>
<template #bottom>
<v-virtual-scroll height="290" item-height="70" :items="availableBackups">
<template #default="{ item }">
<v-list-item @click.prevent="openDialog(item, btnEvent.IMPORT_EVENT)">
<v-list-item-avatar>
<v-icon large dark :color="color">
{{ $globals.icons.database }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
<v-list-item-subtitle>
{{ $d(Date.parse(item.date), "medium") }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click.stop="openDialog(item, btnEvent.DELETE_EVENT)">
<v-icon color="error">{{ $globals.icons.delete }}</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
</v-virtual-scroll>
</template>
</BaseStatCard>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import AdminBackupImportDialog from "./AdminBackupImportDialog.vue";
const IMPORT_EVENT = "import";
const DELETE_EVENT = "delete";
export default defineComponent({
components: { AdminBackupImportDialog },
layout: "admin",
setup() {
return {};
},
data() {
return {
color: "accent",
selectedName: "",
selectedDate: "",
loading: false,
events: [],
availableBackups: [],
btnEvent: { IMPORT_EVENT, DELETE_EVENT },
};
},
computed: {
total() {
return this.availableBackups.length || 0;
},
},
});
</script>
<style scoped>
</style>

View file

@ -0,0 +1,110 @@
<template>
<div>
<!-- <BaseDialog
ref="deleteEventConfirm"
:title="$t('events.delete-event')"
:message="$t('general.confirm-delete-generic')"
color="error"
:icon="$globals.icons.alertCircle"
@confirm="emitDelete()"
/> -->
<BaseStatCard :icon="$globals.icons.bellAlert" :color="color">
<template #after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("settings.events") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ total }} </small>
</h3>
</div>
</template>
<div class="d-flex row py-3 justify-end">
<v-btn class="mx-2" small color="error lighten-1" @click="deleteAll">
<v-icon left> {{ $globals.icons.notificationClearAll }} </v-icon> {{ $t("general.clear") }}
</v-btn>
</div>
<template #bottom>
<v-virtual-scroll height="290" item-height="70" :items="events">
<template #default="{ item }">
<v-list-item>
<v-list-item-avatar>
<v-icon large dark :color="icons[item.category].color">
{{ icons[item.category].icon }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.title"></v-list-item-title>
<v-list-item-subtitle v-text="item.text"></v-list-item-subtitle>
<v-list-item-subtitle>
{{ $d(Date.parse(item.timeStamp), "long") }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click="openDialog(item)">
<v-icon color="error">{{ $globals.icons.delete }}</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
</v-virtual-scroll>
</template>
</BaseStatCard>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
layout: "admin",
setup() {
return {};
},
data() {
return {
color: "accent",
total: 0,
selectedId: "",
events: [],
icons: {
general: {
icon: this.$globals.icons.information,
color: "info",
},
recipe: {
icon: this.$globals.icons.primary,
color: "primary",
},
backup: {
icon: this.$globals.icons.database,
color: "primary",
},
schedule: {
icon: this.$globals.icons.calendar,
color: "primary",
},
migration: {
icon: this.$globals.icons.backupRestore,
color: "primary",
},
user: {
icon: this.$globals.icons.user,
color: "accent",
},
group: {
icon: this.$globals.icons.group,
color: "accent",
},
},
};
},
});
</script>
<style scoped>
</style>

View file

@ -0,0 +1,159 @@
<template>
<BaseStatCard :icon="$globals.icons.user">
<template #avatar>
<v-avatar color="accent" size="120" class="white--text headline mt-n16">
<img
v-if="!hideImage"
:src="require(`~/static/account.png`)"
@error="hideImage = true"
@load="hideImage = false"
/>
<div v-else>
{{ initials }}
</div>
</v-avatar>
</template>
<template #after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="$t('user.user-id-with-value', { id: user.id })" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ $t("group.group-with-value", { groupID: user.group }) }}</small>
</h3>
</div>
</template>
<template #actions>
<BaseDialog
:title="$t('user.reset-password')"
:title-icon="$globals.icons.lock"
:submit-text="$t('settings.change-password')"
:loading="loading"
:top="true"
@submit="changePassword"
>
<template #activator="{ open }">
<v-btn color="info" class="mr-1" small @click="open">
<v-icon left>{{ $globals.icons.lock }}</v-icon>
{{ $t("settings.change-password") }}
</v-btn>
</template>
<v-card-text>
<v-form ref="passChange">
<v-text-field
v-model="password.current"
:prepend-icon="$globals.icons.lock"
:label="$t('user.current-password')"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.current = !showPassword.current"
></v-text-field>
<v-text-field
v-model="password.newOne"
:prepend-icon="$globals.icons.lock"
:label="$t('user.new-password')"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newOne = !showPassword.newOne"
></v-text-field>
<v-text-field
v-model="password.newTwo"
:prepend-icon="$globals.icons.lock"
:label="$t('user.confirm-password')"
:rules="[password.newOne === password.newTwo || $t('user.password-must-match')]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newTwo = !showPassword.newTwo"
></v-text-field>
</v-form>
</v-card-text>
</BaseDialog>
</template>
<template #bottom>
<v-card-text>
<v-form ref="userUpdate">
<v-text-field v-model="user.username" :label="$t('user.username')" required validate-on-blur> </v-text-field>
<v-text-field v-model="user.fullName" :label="$t('user.full-name')" required validate-on-blur> </v-text-field>
<v-text-field v-model="user.email" :label="$t('user.email')" validate-on-blur required> </v-text-field>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pb-1 pt-3">
<AppButtonUpload :icon="$globals.icons.fileImage" :text="$t('user.upload-photo')" file-name="profile_image" />
<v-spacer></v-spacer>
<BaseButton update @click="updateUser" />
</v-card-actions>
</template>
</BaseStatCard>
</template>
<script>
export default {
data() {
return {
hideImage: false,
passwordLoading: false,
password: {
current: "",
newOne: "",
newTwo: "",
},
showPassword: false,
loading: false,
user: {},
};
},
watch: {
userProfileImage() {
this.hideImage = false;
},
},
methods: {
async refreshProfile() {
const [response, err] = await api.users.self();
if (err) {
return; // TODO: Log or Notifty User of Error
}
this.user = response.data;
},
openAvatarPicker() {
this.showAvatarPicker = true;
},
selectAvatar(avatar) {
this.user.avatar = avatar;
},
async updateUser() {
if (!this.$refs.userUpdate.validate()) {
return;
}
this.loading = true;
const response = await api.users.update(this.user);
if (response) {
this.$store.commit("setToken", response.data.access_token);
this.refreshProfile();
this.loading = false;
this.$store.dispatch("requestUserData");
}
},
async changePassword() {
this.paswordLoading = true;
const data = {
currentPassword: this.password.current,
newPassword: this.password.newOne,
};
if (this.$refs.passChange.validate()) {
if (await api.users.changePassword(this.user.id, data)) {
this.$emit("refresh");
}
}
this.paswordLoading = false;
},
},
};
</script>
<style></style>

View file

@ -0,0 +1,211 @@
<template>
<div>
<BaseStatCard :icon="$globals.icons.formatColorFill" :color="color">
<template #after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="$t('general.themes')" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ selectedTheme.name }} </small>
</h3>
</div>
</template>
<template #actions>
<v-btn-toggle v-model="darkMode" color="primary " mandatory>
<v-btn small value="system">
<v-icon>{{ $globals.icons.desktopTowerMonitor }}</v-icon>
<span v-show="$vuetify.breakpoint.smAndUp" class="ml-1">
{{ $t("settings.theme.default-to-system") }}
</span>
</v-btn>
<v-btn small value="light">
<v-icon>{{ $globals.icons.weatherSunny }}</v-icon>
<span v-show="$vuetify.breakpoint.smAndUp" class="ml-1">
{{ $t("settings.theme.light") }}
</span>
</v-btn>
<v-btn small value="dark">
<v-icon>{{ $globals.icons.weatherNight }}</v-icon>
<span v-show="$vuetify.breakpoint.smAndUp" class="ml-1">
{{ $t("settings.theme.dark") }}
</span>
</v-btn>
</v-btn-toggle>
</template>
<template #bottom>
<v-virtual-scroll height="290" item-height="70" :items="availableThemes" class="mt-2">
<template #default="{ item }">
<v-divider></v-divider>
<v-list-item @click="selectedTheme = item">
<v-list-item-avatar>
<v-icon large dark :color="item.colors.primary">
{{ $globals.icons.formatColorFill }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
<v-row flex align-center class="mt-2 justify-space-around px-4 pb-2">
<v-sheet
v-for="(clr, index) in item.colors"
:key="index"
class="rounded flex mx-1"
:color="clr"
height="20"
>
</v-sheet>
</v-row>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click.stop="editTheme(item)">
<v-icon color="accent">{{ $globals.icons.edit }}</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
<v-divider></v-divider>
</template>
</v-virtual-scroll>
<v-divider></v-divider>
<v-card-actions>
<BaseButton class="ml-auto mt-1 mb-n1" create @click="createTheme" />
</v-card-actions>
</template>
</BaseStatCard>
<BaseDialog
ref="themeDialog"
:loading="loading"
:title="modalLabel.title"
:title-icon="$globals.icons.formatColorFill"
modal-width="700"
:submit-text="modalLabel.button"
@submit="processSubmit"
@delete="deleteTheme"
>
<v-card-text class="mt-3">
<v-text-field
v-model="defaultData.name"
:label="$t('settings.theme.theme-name')"
:append-outer-icon="jsonEditor ? $globals.icons.formSelect : $globals.icons.codeBraces"
@click:append-outer="jsonEditor = !jsonEditor"
></v-text-field>
<v-row v-if="defaultData.colors && !jsonEditor" dense dflex wrap justify-content-center>
<v-col v-for="(_, key) in defaultData.colors" :key="key" cols="12" sm="6">
<BaseColorPicker v-model="defaultData.colors[key]" :button-text="labels[key]" />
</v-col>
</v-row>
<!-- <VJsoneditor v-else v-model="defaultData" height="250px" :options="jsonEditorOptions" @error="logError()" /> -->
</v-card-text>
</BaseDialog>
</div>
</template>
<script>
export default {
components: {
// VJsoneditor: () => import(/* webpackChunkName: "json-editor" */ "v-jsoneditor"),
},
data() {
return {
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
availableThemes: [],
color: "accent",
newTheme: false,
loading: false,
defaultData: {
name: "",
colors: {
primary: "#E58325",
accent: "#00457A",
secondary: "#973542",
success: "#43A047",
info: "#4990BA",
warning: "#FF4081",
error: "#EF5350",
},
},
};
},
computed: {
labels() {
return {
primary: this.$t("settings.theme.primary"),
secondary: this.$t("settings.theme.secondary"),
accent: this.$t("settings.theme.accent"),
success: this.$t("settings.theme.success"),
info: this.$t("settings.theme.info"),
warning: this.$t("settings.theme.warning"),
error: this.$t("settings.theme.error"),
};
},
modalLabel() {
if (this.newTheme) {
return {
title: this.$t("settings.add-a-new-theme"),
button: this.$t("general.create"),
};
} else {
return {
title: "Update Theme",
button: this.$t("general.update"),
};
}
},
selectedTheme: {
set(val) {
console.log(val);
},
get() {
return this.$vuetify.theme;
},
},
darkMode: {
set(val) {
console.log(val);
},
get() {
return false;
},
},
},
methods: {
async getAllThemes() {
this.availableThemes = await api.themes.requestAll();
},
editTheme(theme) {
this.defaultData = theme;
this.newTheme = false;
this.$refs.themeDialog.open();
},
createTheme() {
this.newTheme = true;
this.$refs.themeDialog.open();
},
async processSubmit() {
if (this.newTheme) {
await api.themes.create(this.defaultData);
} else {
await api.themes.update(this.defaultData);
}
this.getAllThemes();
},
async deleteTheme() {
await api.themes.delete(this.defaultData.id);
this.getAllThemes();
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -250,7 +250,7 @@ export default defineComponent({
this.$router.push(`/recipe/${response.data.slug}`);
},
async manualCreateRecipe() {
await this.api.recipes.createOne(this.createRecipeData.form.name);
await this.api.recipes.createOne({ name: this.createRecipeData.form.name });
},
async createOnByUrl() {
this.error = false;

View file

@ -1,5 +1,5 @@
<template>
<v-navigation-drawer :value="value" clipped app width="200px">
<v-navigation-drawer :value="value" clipped app>
<!-- User Profile -->
<template v-if="$auth.user">
<v-list-item two-line to="/user/profile">
@ -14,11 +14,11 @@
</v-list-item>
<v-divider></v-divider>
</template>
<!-- Primary Links -->
<v-list nav dense>
<v-list-item-group v-model="topSelected" color="primary">
<v-list-item v-for="nav in topLink" :key="nav.title" link :to="nav.to">
<v-list-item v-for="nav in topLink" :key="nav.title" exact link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
@ -29,16 +29,40 @@
<!-- Secondary Links -->
<template v-if="secondaryLinks">
<v-subheader v-if="secondaryHeader" class="pb-0">{{ secondaryHeader }}</v-subheader>
<v-divider></v-divider>
<v-list nav dense>
<v-list-item-group v-model="secondarySelected" color="primary">
<v-list-item v-for="nav in secondaryLinks" :key="nav.title" link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list-item-group>
<template v-for="nav in secondaryLinks">
<!-- Multi Items -->
<v-list-group
v-if="nav.children"
:key="nav.title + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
>
<template #activator>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template>
<v-list-item v-for="child in nav.children" :key="child.title" :to="child.to">
<v-list-item-icon>
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
</v-list-group>
<!-- Single Item -->
<v-list-item-group v-else :key="nav.title + 'single-item'" v-model="secondarySelected" color="primary">
<v-list-item link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list-item-group>
</template>
</v-list>
</template>
@ -93,12 +117,17 @@ export default defineComponent({
required: false,
default: null,
},
secondaryHeader: {
type: String,
default: null,
},
},
setup() {
return {};
},
data() {
return {
dropDowns: {},
topSelected: null,
secondarySelected: null,
bottomSelected: null,

View file

@ -18,6 +18,7 @@
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:disabled="updateMode && inputField.fixed"
@change="emitBlur"
/>

View file

@ -1,6 +1,7 @@
<template>
<v-card flat class="pb-2">
<h2>{{ title }}</h2>
<h2 class="headline">{{ title }}</h2>
<BaseDivider width="200px" color="primary" class="my-2" thickness="1px" />
<p class="pb-0 mb-0">
<slot />
</p>
@ -12,8 +13,8 @@ export default {
props: {
title: {
type: String,
default: "Place Holder"
}
}
}
default: "Place Holder",
},
},
};
</script>

View file

@ -0,0 +1,64 @@
<template>
<div>
<div class="text-center">
<h3>{{ buttonText }}</h3>
</div>
<v-text-field v-model="color" hide-details class="ma-0 pa-0" solo>
<template #append>
<v-menu v-model="menu" top nudge-bottom="105" nudge-left="16" :close-on-content-click="false">
<template #activator="{ on }">
<div :style="swatchStyle" swatches-max-height="300" v-on="on" />
</template>
<v-card>
<v-card-text class="pa-0">
<v-color-picker v-model="color" flat mode="hexa" show-swatches />
</v-card-text>
</v-card>
</v-menu>
</template>
</v-text-field>
</div>
</template>
<script>
export default {
props: {
buttonText: String,
value: String,
},
data() {
return {
dialog: false,
swatches: false,
color: this.value || "#1976D2",
mask: "!#XXXXXXXX",
menu: false,
};
},
computed: {
swatchStyle() {
const { value, menu } = this;
return {
backgroundColor: value,
cursor: "pointer",
height: "30px",
width: "30px",
borderRadius: menu ? "50%" : "4px",
transition: "border-radius 200ms ease-in-out",
};
},
},
watch: {
color() {
this.updateColor();
},
},
methods: {
updateColor() {
this.$emit("input", this.color);
},
},
};
</script>
<style></style>

View file

@ -33,9 +33,12 @@
<BaseButton v-if="$listeners.delete" delete secondary @click="deleteEvent" />
<BaseButton v-if="$listeners.confirm" :color="color" type="submit" @click="$emit('confirm')">
<template #icon>
{{ $globals.icons.check }}
</template>
{{ $t("general.confirm") }}
</BaseButton>
<BaseButton v-else-if="$listeners.submit" type="submit" @click="submitEvent">
<BaseButton v-if="$listeners.submit" type="submit" @click="submitEvent">
{{ submitText }}
</BaseButton>
</slot>
@ -108,6 +111,7 @@ export default defineComponent({
},
dialog(val) {
if (val) this.submitted = false;
if (!val) this.$emit("close");
},
},
methods: {
@ -130,4 +134,9 @@ export default defineComponent({
});
</script>
<style></style>
<style>
.top-dialog {
position: fixed;
top: 0;
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<v-divider :width="width" class="mx-auto" :class="color" :style="`border-width: ${thickness} !important`" />
<v-divider :width="width" :class="color" :style="`border-width: ${thickness} !important`" />
</template>
<script>
@ -7,16 +7,16 @@ export default {
props: {
width: {
type: String,
default: "100px"
default: "100px",
},
thickness: {
type: String,
default: "2px"
default: "2px",
},
color: {
type: String,
default: "accent"
}
}
}
default: "accent",
},
},
};
</script>

View file

@ -0,0 +1,103 @@
w<template>
<v-card v-bind="$attrs" :class="classes" class="v-card--material pa-3">
<div class="d-flex grow flex-wrap">
<slot name="avatar">
<v-sheet
:color="color"
:max-height="icon ? 90 : undefined"
:width="icon ? 'auto' : '100%'"
elevation="6"
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
dark
>
<v-icon v-if="icon" size="40" v-text="icon" />
<div v-if="text" class="headline font-weight-thin" v-text="text" />
</v-sheet>
</slot>
<div v-if="$slots['after-heading']" class="ml-auto">
<slot name="after-heading" />
</div>
</div>
<slot />
<template v-if="$slots.actions">
<v-divider class="mt-2" />
<v-card-actions class="pb-0">
<slot name="actions" />
</v-card-actions>
</template>
<template v-if="$slots.bottom">
<v-divider v-if="!$slots.actions" class="mt-2" />
<div class="pb-0">
<slot name="bottom" />
</div>
</template>
</v-card>
</template>
<script>
export default {
name: "MaterialCard",
props: {
avatar: {
type: String,
default: "",
},
color: {
type: String,
default: "primary",
},
icon: {
type: String,
default: undefined,
},
image: {
type: Boolean,
default: false,
},
text: {
type: String,
default: "",
},
title: {
type: String,
default: "",
},
},
computed: {
classes() {
return {
"v-card--material--has-heading": this.hasHeading,
"mt-3": this.$vuetify.breakpoint.name === "xs" || this.$vuetify.breakpoint.name === "sm",
};
},
hasHeading() {
return false;
},
hasAltHeading() {
return false;
},
},
};
</script>
<style lang="sass">
.v-card--material
&__avatar
position: relative
top: -64px
margin-bottom: -32px
&__heading
position: relative
top: -40px
transition: .3s ease
z-index: 1
</style>

View file

@ -0,0 +1,51 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
import { CreateGroup } from "~/api/class-interfaces/groups";
export const useGroups = function () {
const api = useApiSingleton();
const loading = ref(false);
function getAllGroups() {
loading.value = true;
const asyncKey = String(Date.now());
const groups = useAsync(async () => {
const { data } = await api.groups.getAll();
return data;
}, asyncKey);
loading.value = false;
return groups;
}
async function refreshAllGroups() {
loading.value = true;
const { data } = await api.groups.getAll();
groups.value = data;
loading.value = false;
}
async function deleteGroup(id: string | number) {
loading.value = true;
const { data } = await api.groups.deleteOne(id);
loading.value = false;
refreshAllGroups();
return data;
}
async function createGroup(payload: CreateGroup) {
console.log(payload);
loading.value = true;
const { data } = await api.groups.createOne(payload);
if (data && groups.value) {
groups.value.push(data);
}
}
const groups = getAllGroups();
return { groups, getAllGroups, refreshAllGroups, deleteGroup, createGroup };
};

View file

@ -15,7 +15,6 @@ export const useRecipeContext = function () {
}, slug);
loading.value = false
return recipe;
}

View file

@ -0,0 +1,93 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
import { UserIn, UserOut } from "~/types/api-types/user";
/*
TODO: Potentiall combine useAllUsers and useUser by delaying the get all users functinality
Unsure how this could work but still be clear and functional. Perhaps by passing arguments to the useUsers function
to control whether the object is substantiated... but some of the others rely on it being substantiated...Will come back to this.
*/
export const useAllUsers = function () {
const api = useApiSingleton();
const loading = ref(false);
function getAllUsers() {
loading.value = true;
const asyncKey = String(Date.now());
const allUsers = useAsync(async () => {
const { data } = await api.users.getAll();
return data;
}, asyncKey);
loading.value = false;
return allUsers;
}
async function refreshAllUsers() {
loading.value = true;
const { data } = await api.users.getAll();
users.value = data;
loading.value = false;
}
const users = getAllUsers();
return { users, refreshAllUsers };
};
export const useUser = function (refreshFunc: CallableFunction | null = null) {
const api = useApiSingleton();
const loading = ref(false);
function getUser(id: string) {
loading.value = true;
const user = useAsync(async () => {
const { data } = await api.users.getOne(id);
return data;
}, id);
loading.value = false;
return user;
}
async function createUser(payload: UserIn) {
loading.value = true;
const { data } = await api.users.createOne(payload);
console.log(payload, data);
if (refreshFunc) {
refreshFunc();
}
loading.value = false;
return data;
}
async function deleteUser(id: string) {
loading.value = true;
const { data } = await api.users.deleteOne(id);
loading.value = false;
if (refreshFunc) {
refreshFunc();
}
return data;
}
async function updateUser(slug: string, user: UserOut) {
loading.value = true;
const { data } = await api.users.updateOne(slug, user);
loading.value = false;
if (refreshFunc) {
refreshFunc();
}
return data;
}
return { loading, getUser, deleteUser, updateUser, createUser };
};

View file

@ -9,6 +9,7 @@
:secondary-links="$auth.user.admin ? adminLinks : null"
:bottom-links="$auth.user.admin ? bottomLinks : null"
:user="{ data: true }"
:secondary-header="$t('user.admin')"
@input="sidebar = !sidebar"
/>
@ -47,6 +48,16 @@ export default defineComponent({
to: "/user/profile",
title: this.$t("sidebar.profile"),
},
{
icon: this.$globals.icons.group,
to: "/user/group",
title: this.$t("group.group"),
},
{
icon: this.$globals.icons.pages,
to: "/user/group/pages",
title: this.$t("settings.pages"),
},
],
adminLinks: [
{
@ -63,11 +74,45 @@ export default defineComponent({
icon: this.$globals.icons.tools,
to: "/admin/toolbox",
title: this.$t("sidebar.toolbox"),
children: [
{
icon: this.$globals.icons.bellAlert,
to: "/admin/toolbox/notifications",
title: this.$t("events.notification"),
},
{
icon: this.$globals.icons.tags,
to: "/admin/toolbox/categories",
title: this.$t("sidebar.tags"),
},
{
icon: this.$globals.icons.tags,
to: "/admin/toolbox/tags",
title: this.$t("sidebar.categories"),
},
{
icon: this.$globals.icons.broom,
to: "/admin/toolbox/organize",
title: this.$t("settings.organize"),
},
],
},
{
icon: this.$globals.icons.group,
to: "/admin/manage-users",
title: this.$t("sidebar.manage-users"),
children: [
{
icon: this.$globals.icons.user,
to: "/admin/manage-users/all-users",
title: this.$t("user.users"),
},
{
icon: this.$globals.icons.group,
to: "/admin/manage-users/all-groups",
title: this.$t("group.groups"),
},
],
},
{
icon: this.$globals.icons.import,
@ -89,6 +134,9 @@ export default defineComponent({
],
};
},
head: {
title: "Admin",
},
});
</script>

View file

@ -64,6 +64,9 @@ export default defineComponent({
],
};
},
head: {
title: "Home",
},
});
</script>

View file

@ -1,8 +1,8 @@
export default {
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
titleTemplate: "%s - frontend",
title: "frontend",
titleTemplate: "%s - Mealie",
title: "Home",
meta: [
{ charset: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },

View file

@ -1,5 +1,7 @@
<template>
<div></div>
<v-container fluid>
<BaseCardSectionTitle title="About Mealie"> </BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">

View file

@ -1,15 +1,113 @@
<template>
<div></div>
<v-container class="mt-10">
<v-row>
<v-col cols="12" sm="12" md="4">
<BaseStatCard :icon="$globals.icons.primary">
<template #after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("general.recipes") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalRecipes }}</small>
</h3>
</div>
</template>
<template #actions>
<div class="d-flex row py-2 justify-end">
<v-btn class="ma-1" small color="primary" to="/admin/toolbox/organize">
<v-icon left> {{ $globals.icons.tags }} </v-icon>
{{ $tc("tag.untagged-count", [statistics.untaggedRecipes]) }}
</v-btn>
<v-btn class="ma-1" small color="primary" to="/admin/toolbox/organize">
<v-icon left> {{ $globals.icons.tags }} </v-icon>
{{ $tc("category.uncategorized-count", [statistics.uncategorizedRecipes]) }}
</v-btn>
</div>
</template>
</BaseStatCard>
</v-col>
<v-col cols="12" sm="12" md="4">
<BaseStatCard :icon="$globals.icons.user">
<template #after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("user.users") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalUsers }}</small>
</h3>
</div>
</template>
<template #actions>
<div class="ml-auto">
<v-btn color="primary" small to="/admin/manage-users/all-users">
<v-icon left>{{ $globals.icons.user }}</v-icon>
{{ $t("user.manage-users") }}
</v-btn>
</div>
</template>
</BaseStatCard>
</v-col>
<v-col cols="12" sm="12" md="4">
<BaseStatCard :icon="$globals.icons.group">
<template #after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("group.groups") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalGroups }}</small>
</h3>
</div>
</template>
<template #actions>
<div class="ml-auto">
<v-btn color="primary" small to="/admin/manage-users/all-groups">
<v-icon left>{{ $globals.icons.group }}</v-icon>
{{ $t("group.manage-groups") }}
</v-btn>
</div>
</template>
</BaseStatCard>
</v-col>
</v-row>
<v-row class="mt-10" align-content="stretch">
<v-col cols="12" sm="12" lg="6">
<AdminEventViewer />
</v-col>
<v-col cols="12" sm="12" lg="6">
<AdminBackupViewer />
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import AdminEventViewer from "@/components/Domain/Admin/AdminEventViewer.vue";
import AdminBackupViewer from "@/components/Domain/Admin/AdminBackupViewer.vue";
export default defineComponent({
components: { AdminEventViewer, AdminBackupViewer },
layout: "admin",
setup() {
return {};
},
data() {
return {
statistics: {
totalGroups: 0,
totalRecipes: 0,
totalUsers: 0,
uncategorizedRecipes: 0,
untaggedRecipes: 0,
},
};
},
});
</script>

View file

@ -0,0 +1,122 @@
// TODO: Add Loading Indicator...Maybe?
// TODO: Edit Group
<template>
<v-container fluid>
<BaseCardSectionTitle title="Group Management"> </BaseCardSectionTitle>
<section>
<v-toolbar flat class="justify-between">
<BaseDialog
ref="refUserDialog"
top
:title="$t('group.create-group')"
@submit="createGroup(createUserForm.data)"
>
<template #activator="{ open }">
<BaseButton @click="open"> {{ $t("group.create-group") }} </BaseButton>
</template>
<v-card-text>
<AutoForm v-model="createUserForm.data" :update-mode="updateMode" :items="createUserForm.items" />
</v-card-text>
</BaseDialog>
</v-toolbar>
<v-data-table
:headers="headers"
:items="groups || []"
item-key="id"
class="elevation-0"
hide-default-footer
disable-pagination
:search="search"
>
<template #item.mealplans="{ item }">
{{ item.mealplans.length }}
</template>
<template #item.shoppingLists="{ item }">
{{ item.shoppingLists.length }}
</template>
<template #item.users="{ item }">
{{ item.users.length }}
</template>
<template #item.webhookEnable="{ item }">
{{ item.webhookEnabled ? $t("general.yes") : $t("general.no") }}
</template>
<template #item.actions="{ item }">
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="deleteGroup(item.id)">
<template #activator="{ open }">
<v-btn :disabled="item && item.users.length > 0" class="mr-1" small color="error" @click="open">
<v-icon small left>
{{ $globals.icons.delete }}
</v-icon>
{{ $t("general.delete") }}
</v-btn>
<v-btn small color="success" @click="updateUser(item)">
<v-icon small left class="mr-2">
{{ $globals.icons.edit }}
</v-icon>
{{ $t("general.edit") }}
</v-btn>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
</template>
</v-data-table>
<v-divider></v-divider>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms";
import { useApiSingleton } from "~/composables/use-api";
import { useGroups } from "~/composables/use-groups";
export default defineComponent({
layout: "admin",
setup() {
const api = useApiSingleton();
const { groups, refreshAllGroups, deleteGroup, createGroup } = useGroups();
return { api, groups, refreshAllGroups, deleteGroup, createGroup };
},
data() {
return {
search: "",
headers: [
{
text: this.$t("group.group"),
align: "start",
sortable: false,
value: "id",
},
{ text: this.$t("general.name"), value: "name" },
{ text: this.$t("user.total-users"), value: "users" },
{ text: this.$t("user.webhooks-enabled"), value: "webhookEnable" },
{ text: this.$t("user.total-mealplans"), value: "mealplans" },
{ text: this.$t("shopping-list.shopping-lists"), value: "shoppingLists" },
{ value: "actions" },
],
updateMode: false,
createUserForm: {
items: [
{
label: "Group Name",
varName: "name",
type: fieldTypes.TEXT,
rules: ["required"],
},
],
data: {
name: "",
},
},
};
},
});
</script>
<style scoped>
</style>

View file

@ -0,0 +1,178 @@
// TODO: Edit User
<template>
<v-container fluid>
<BaseCardSectionTitle title="User Management"> </BaseCardSectionTitle>
<section>
<v-toolbar flat class="justify-between">
<BaseDialog
ref="refUserDialog"
top
:title="$t('user.create-user')"
@submit="createUser(createUserForm.data)"
@close="resetForm"
>
<template #activator="{ open }">
<BaseButton @click="open"> {{ $t("user.create-user") }} </BaseButton>
</template>
<v-card-text>
<v-select
v-model="createUserForm.data.group"
:items="groups"
rounded
class="rounded-lg"
item-text="name"
item-value="name"
:return-object="false"
filled
label="Filled style"
></v-select>
<AutoForm v-model="createUserForm.data" :update-mode="updateMode" :items="createUserForm.items" />
</v-card-text>
</BaseDialog>
</v-toolbar>
<v-data-table
:headers="headers"
:items="users || []"
item-key="id"
class="elevation-0"
hide-default-footer
disable-pagination
:search="search"
>
<template #item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template #item.actions="{ item }">
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="deleteUser(item.id)">
<template #activator="{ open }">
<v-btn :disabled="item.id == 1" class="mr-1" small color="error" @click="open">
<v-icon small left>
{{ $globals.icons.delete }}
</v-icon>
{{ $t("general.delete") }}
</v-btn>
<v-btn small color="success" @click="updateUser(item)">
<v-icon small left class="mr-2">
{{ $globals.icons.edit }}
</v-icon>
{{ $t("general.edit") }}
</v-btn>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
</template>
</v-data-table>
<v-divider></v-divider>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms";
import { useApiSingleton } from "~/composables/use-api";
import { useGroups } from "~/composables/use-groups";
import { useUser, useAllUsers } from "~/composables/use-user";
export default defineComponent({
layout: "admin",
setup() {
const api = useApiSingleton();
const refUserDialog = ref();
const { groups } = useGroups();
const { users, refreshAllUsers } = useAllUsers();
const { loading, getUser, deleteUser, createUser } = useUser(refreshAllUsers);
return { refUserDialog, api, users, deleteUser, createUser, getUser, loading, groups };
},
data() {
return {
search: "",
headers: [
{
text: this.$t("user.user-id"),
align: "start",
sortable: false,
value: "id",
},
{ text: this.$t("user.username"), value: "username" },
{ text: this.$t("user.full-name"), value: "fullName" },
{ text: this.$t("user.email"), value: "email" },
{ text: this.$t("group.group"), value: "group" },
{ text: this.$t("user.admin"), value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
updateMode: false,
createUserForm: {
items: [
{
label: "User Name",
varName: "username",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Full Name",
varName: "fullName",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Email",
varName: "email",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Passord",
varName: "password",
fixed: true,
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Administrator",
varName: "admin",
type: fieldTypes.BOOLEAN,
rules: ["required"],
},
],
data: {
username: "",
fullName: "",
email: "",
admin: false,
group: "",
favoriteRecipes: [],
password: "",
},
},
};
},
methods: {
updateUser(userData: any) {
this.updateMode = true;
this.createUserForm.data = userData;
this.refUserDialog.open();
},
resetForm() {
this.createUserForm.data = {
username: "",
fullName: "",
email: "",
admin: false,
group: "",
favoriteRecipes: [],
password: "",
};
},
},
});
</script>
<style scoped>
</style>

View file

@ -1,7 +1,14 @@
<template>
<div></div>
<v-container fluid>
<BaseCardSectionTitle title="Data Migrations">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda
earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem
praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat
distinctio illum nemo. Dicta, doloremque!
</BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
@ -12,6 +19,6 @@ export default defineComponent({
},
});
</script>
<style scoped>
</style>

View file

@ -1,7 +1,14 @@
<template>
<div></div>
<v-container fluid>
<BaseCardSectionTitle title="Sitewide Settings">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda
earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem
praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat
distinctio illum nemo. Dicta, doloremque!
</BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
@ -12,6 +19,6 @@ export default defineComponent({
},
});
</script>
<style scoped>
</style>

View file

@ -0,0 +1,19 @@
<template>
<v-container fluid>
<BaseCardSectionTitle title="Manage Categories"> </BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
layout: "admin",
setup() {
return {};
},
});
</script>
<style scoped>
</style>

View file

@ -0,0 +1,21 @@
<template>
<v-container fluid>
<BaseCardSectionTitle title="Event Notifications">
{{ $t("events.new-notification-form-description") }}
</BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
layout: "admin",
setup() {
return {};
},
});
</script>
<style scoped>
</style>

View file

@ -1,7 +1,9 @@
<template>
<div></div>
<v-container fluid>
<BaseCardSectionTitle title="Organize Recipes"> </BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
@ -12,6 +14,6 @@ export default defineComponent({
},
});
</script>
<style scoped>
</style>

View file

@ -1,7 +1,9 @@
<template>
<div></div>
<v-container fluid>
<BaseCardSectionTitle title="Manage Tags"> </BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
@ -12,6 +14,6 @@ export default defineComponent({
},
});
</script>
<style scoped>
</style>

View file

@ -165,7 +165,7 @@
</template>
<script lang="ts">
import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api";
import { defineComponent, ref, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
import { useApiSingleton } from "~/composables/use-api";
@ -212,6 +212,8 @@ export default defineComponent({
const form = ref<boolean>(false);
useMeta(() => ({ title: recipe?.value?.name || "Recipe" }));
async function updateRecipe(slug: string, recipe: Recipe) {
const { data } = await api.recipes.updateOne(slug, recipe);
form.value = false;
@ -263,6 +265,7 @@ export default defineComponent({
},
};
},
head: {},
computed: {
imageHeight() {
// @ts-ignore

View file

@ -2,7 +2,7 @@
<div></div>
</template>
<script lang="ts">
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
@ -12,5 +12,5 @@ export default defineComponent({
});
</script>
<style scoped>
<style scoped>
</style>

View file

@ -0,0 +1,24 @@
<template>
<v-container fluid>
<BaseCardSectionTitle title="Group Settings">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda
earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem
praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat
distinctio illum nemo. Dicta, doloremque!
</BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
layout: "admin",
setup() {
return {};
},
});
</script>
<style scoped>
</style>

View file

@ -0,0 +1,24 @@
<template>
<v-container fluid>
<BaseCardSectionTitle title="Group Pages">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda
earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem
praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat
distinctio illum nemo. Dicta, doloremque!
</BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
layout: "admin",
setup() {
return {};
},
});
</script>
<style scoped>
</style>

View file

@ -1,11 +1,23 @@
<template>
<div></div>
<v-container fluid>
<v-row>
<v-col cols="12" sm="12" md="12" lg="6">
<UserProfileCard class="mt-14" />
</v-col>
<v-col cols="12" sm="12" md="12" lg="6">
<UserThemeCard class="mt-14" />
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import UserProfileCard from "~/components/Domain/User/UserProfileCard.vue";
import UserThemeCard from "~/components/Domain/User/UserThemeCard.vue";
export default defineComponent({
components: { UserProfileCard, UserThemeCard },
layout: "admin",
setup() {
return {};

View file

@ -5,6 +5,10 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export interface CreateRecipe {
name: string;
}
export interface AllRecipeRequest {
properties: string[];
limit?: number;

View file

@ -1,16 +1,15 @@
import { AxiosResponse } from "axios";
interface RequestResponse<T> {
response: AxiosResponse<T> | null;
data: T | null;
error: any;
}
export interface ApiRequestInstance {
get<T>(url: string, data?: T | object): Promise<RequestResponse<T>>;
post<T>(url: string, data: T | object): Promise<RequestResponse<T>>;
put<T>(url: string, data: T | object): Promise<RequestResponse<T>>;
patch<T>(url: string, data: T | object): Promise<RequestResponse<T>>;
delete<T>(url: string, data?: T | object): Promise<RequestResponse<T>>;
response: AxiosResponse<T> | null;
data: T | null;
error: any;
}
export interface ApiRequestInstance {
get<T>(url: string, data?: T | object): Promise<RequestResponse<T>>;
post<T>(url: string, data: T | object | any): Promise<RequestResponse<T>>;
put<T>(url: string, data: T | object): Promise<RequestResponse<T>>;
patch<T>(url: string, data: T | object): Promise<RequestResponse<T>>;
delete<T>(url: string, data?: T | object): Promise<RequestResponse<T>>;
}

View file

@ -31,7 +31,7 @@ async def get_current_user_group(
return db.groups.get(session, current_user.group, "name")
@admin_router.post("", status_code=status.HTTP_201_CREATED)
@admin_router.post("", status_code=status.HTTP_201_CREATED, response_model=GroupInDB)
async def create_group(
background_tasks: BackgroundTasks,
group_data: GroupBase,
@ -40,8 +40,9 @@ async def create_group(
""" Creates a Group in the Database """
try:
db.groups.create(session, group_data.dict())
new_group = db.groups.create(session, group_data.dict())
background_tasks.add_task(create_group_event, "Group Created", f"'{group_data.name}' created", session)
return new_group
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View file

@ -9,7 +9,7 @@ from sqlalchemy.orm.session import Session
router = APIRouter(tags=["Query All Recipes"])
@router.get("/api/recipes/summary", response_model=list[RecipeSummary])
@router.get("/api/recipes", response_model=list[RecipeSummary])
async def get_recipe_summary(
start=0, limit=9999, session: Session = Depends(generate_session), user: bool = Depends(is_logged_in)
):