Feature/authentication (#195)

* basic crud NOT SECURE

* refactor/database init on startup

* added scratch.py

* tests/user CRUD routes

* password hashing

* change app_config location

* bump python version

* formatting

* login ui starter

* change import from url design

* move components

* remove old snackbar

* refactor/Componenet folder structure rework

* refactor/remove old code

* refactor/rename componenets/js files

* remove console.logs

* refactor/ models to schema and sql to models

* new header styling for imports

* token request

* fix url scrapper

* refactor/rename schema files

* split routes file

* redesigned admin page

* enable relative imports for vue components

* refactor/switch to pages view

* add CamelCase package

* majors settings rework

* user management second pass

* super user CRUD

* refactor/consistent models names

* refactor/consistent model names

* password reset

* store refactor

* dependency update

* abstract button props

* profile page refactor

* basic password validation

* login form refactor/split v-container

* remo unused code

* hide editor buttons when not logged in

* mkdocs dev dependency

* v0.4.0 docs update

* profile image upload

* additional token routes

* Smaller recipe cards for smaller viewports

* fix admin sidebar

* add users

* change to outlined

* theme card starter

* code cleanup

* signups

* signup pages

* fix #194

* fix #193

* clarify mealie_port

* fix #184

* fixes #178

* fix blank card error on meal-plan creator

* admin signup

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-03-03 21:09:37 -09:00 committed by GitHub
commit 95b1ab0dec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1323 additions and 354 deletions

View file

@ -1,5 +1,6 @@
{
auto_https off
admin off
}
:80 {

View file

@ -41,12 +41,12 @@ services:
## Env Variables
| Variables | default | description |
| ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| db_type | sqlite | The database type to be used. Current Options 'sqlite' |
| mealie_port | 9000 | The port exposed by mealie. **do not change this if you're running in docker** If you'd like to use another port, map 9000 to another port of the host. |
| api_docs | True | Turns on/off access to the API documentation locally. |
| TZ | UTC | You should set your time zone accordingly so the date/time features work correctly |
| Variables | default | description |
| ----------- | ------- | ----------------------------------------------------------------------------------- |
| db_type | sqlite | The database type to be used. Current Options 'sqlite' |
| mealie_port | 9000 | The port exposed by backend API. **do not change this if you're running in docker** |
| api_docs | True | Turns on/off access to the API documentation locally. |
| TZ | UTC | You should set your time zone accordingly so the date/time features work correctly |
## Deployed as a Python Application

View file

@ -86,6 +86,8 @@ export default {
search: false,
}),
methods: {
// For Later!
/**
* Checks if 'system' is set for dark mode and then sets the corrisponding value for vuetify
*/

View file

@ -7,8 +7,8 @@ import migration from "./migration";
import myUtils from "./upload";
import category from "./category";
import meta from "./meta";
import users from "./users"
import users from "./users";
import signUps from "./signUps";
export default {
recipes: recipe,
@ -20,5 +20,6 @@ export default {
utils: myUtils,
categories: category,
meta: meta,
users: users
users: users,
signUps: signUps,
};

View file

@ -0,0 +1,30 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const signUpPrefix = baseURL + "users/sign-ups";
const signUpURLs = {
all: `${signUpPrefix}`,
createToken: `${signUpPrefix}`,
deleteToken: token => `${signUpPrefix}/${token}`,
createUser: token => `${signUpPrefix}/${token}`,
};
export default {
async getAll() {
let response = await apiReq.get(signUpURLs.all);
return response.data;
},
async createToken(data) {
let response = await apiReq.post(signUpURLs.createToken, data);
return response.data;
},
async deleteToken(token) {
let response = await apiReq.delete(signUpURLs.deleteToken(token));
return response.data;
},
async createUser(token, data) {
let response = await apiReq.post(signUpURLs.createUser(token), data);
return response.data;
},
};

View file

@ -7,6 +7,7 @@ const authURLs = {
token: `${authPrefix}/token`,
};
const usersURLs = {
users: `${userPrefix}`,
self: `${userPrefix}/self`,

View file

@ -0,0 +1,252 @@
<template>
<v-card outlined class="mt-n1">
<Confirmation
ref="deleteUserDialog"
title="Confirm User Deletion"
:message="
`Are you sure you want to delete the user <b>${activeName} ID: ${activeId}<b/>`
"
icon="mdi-alert"
@confirm="deleteUser"
:width="450"
@close="closeDelete"
/>
<v-toolbar flat>
<v-icon large color="accent" class="mr-1">
mdi-account-group
</v-icon>
<v-toolbar-title class="headine">
User Groups
</v-toolbar-title>
<v-spacer> </v-spacer>
<v-dialog v-model="dialog" max-width="600px">
<template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on">
Create Group
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-account
</v-icon>
<v-toolbar-title class="headline">
{{ formTitle }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-title class="headline">
User ID: {{ editedItem.id }}
</v-toolbar-title>
</v-app-bar>
<v-card-text>
<v-form ref="newUser">
<v-row>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.fullName"
label="Full Name"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.email"
label="Email"
:rules="[existsRule, emailRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.family"
label="Family Group"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword">
<v-text-field
v-model="editedItem.password"
label="User Password"
:rules="[existsRule, minRule]"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3">
<v-switch v-model="editedItem.admin" label="Admin"></v-switch>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close">
Cancel
</v-btn>
<v-btn color="primary" @click="save">
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-toolbar>
<v-divider></v-divider>
<v-card-text>
<v-data-table :headers="headers" :items="users" sort-by="calories">
<template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
mdi-delete
</v-icon>
Delete
</v-btn>
<v-btn small color="success" @click="editItem(item)">
<v-icon small left class="mr-2">
mdi-pencil
</v-icon>
Edit
</v-btn>
</template>
<template v-slot:item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template v-slot:no-data>
<v-btn color="primary" @click="initialize">
Reset
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script>
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
import { validators } from "@/mixins/validators";
export default {
components: { Confirmation },
mixins: [validators],
data: () => ({
dialog: false,
activeId: null,
activeName: null,
headers: [
{
text: "User ID",
align: "start",
sortable: false,
value: "id",
},
{ text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" },
{ text: "Family", value: "family" },
{ text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
users: [],
editedIndex: -1,
editedItem: {
id: 0,
fullName: "",
password: "",
email: "",
family: "",
admin: false,
},
defaultItem: {
id: 0,
fullName: "",
password: "",
email: "",
family: "",
admin: false,
},
}),
computed: {
formTitle() {
return this.editedIndex === -1 ? "New User" : "Edit User";
},
showPassword() {
return this.editedIndex === -1 ? true : false;
},
},
watch: {
dialog(val) {
val || this.close();
},
dialogDelete(val) {
val || this.closeDelete();
},
},
created() {
this.initialize();
},
methods: {
async initialize() {
this.users = await api.users.allUsers();
},
async deleteUser() {
await api.users.delete(this.activeId);
this.initialize();
},
editItem(item) {
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.dialog = true;
},
deleteItem(item) {
this.activeId = item.id;
this.activeName = item.fullName;
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.$refs.deleteUserDialog.open();
},
deleteItemConfirm() {
this.users.splice(this.editedIndex, 1);
this.closeDelete();
},
close() {
this.dialog = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
closeDelete() {
this.dialogDelete = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
async save() {
if (this.editedIndex > -1) {
api.users.update(this.editedItem);
this.close();
} else if (this.$refs.newUser.validate()) {
api.users.create(this.editedItem);
this.close();
}
await this.initialize();
},
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,239 @@
<template>
<v-card outlined class="mt-n1">
<Confirmation
ref="deleteUserDialog"
title="Confirm User Deletion"
:message="`Are you sure you want to delete the link <b>${activeName}<b/>`"
icon="mdi-alert"
@confirm="deleteUser"
:width="450"
@close="closeDelete"
/>
<v-toolbar flat>
<v-icon large color="accent" class="mr-1">
mdi-link-variant
</v-icon>
<v-toolbar-title class="headine">
Sign Up Links
</v-toolbar-title>
<v-spacer> </v-spacer>
<v-dialog v-model="dialog" max-width="600px">
<template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on">
Create Link
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-account
</v-icon>
<v-toolbar-title class="headline">
Create Link
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
<v-form ref="newUser">
<v-row class="justify-center mt-3">
<v-text-field
class="mr-2"
v-model="editedItem.name"
label="Link Name"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
<v-checkbox
v-model="editedItem.admin"
label="Admin"
></v-checkbox>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close">
Cancel
</v-btn>
<v-btn color="primary" @click="save">
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-toolbar>
<v-divider></v-divider>
<v-card-text>
<v-data-table :headers="headers" :items="links" sort-by="calories">
<template v-slot:item.token="{ item }">
{{ `${baseURL}/sign-up/${item.token}` }}
<v-btn
icon
class="mr-1"
small
color="accent"
@click="updateClipboard(`${baseURL}/sign-up/${item.token}`)"
>
<v-icon>
mdi-content-copy
</v-icon>
</v-btn>
</template>
<template v-slot:item.admin="{ item }">
<v-btn small :color="item.admin ? 'success' : 'error'" text>
<v-icon small left>
mdi-account-cog
</v-icon>
{{ item.admin ? "Yes" : "No" }}
</v-btn>
</template>
<template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
mdi-delete
</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script>
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
import { validators } from "@/mixins/validators";
export default {
components: { Confirmation },
mixins: [validators],
data: () => ({
dialog: false,
activeId: null,
activeName: null,
headers: [
{
text: "Link ID",
align: "start",
sortable: false,
value: "id",
},
{ text: "Name", value: "name" },
{ text: "Token", value: "token" },
{ text: "Admin", value: "admin", align: "center" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
links: [],
editedIndex: -1,
editedItem: {
name: "",
admin: false,
token: "",
id: 0,
},
defaultItem: {
name: "",
token: "",
admin: false,
id: 0,
},
}),
computed: {
baseURL() {
return window.location.origin;
},
},
watch: {
dialog(val) {
val || this.close();
},
dialogDelete(val) {
val || this.closeDelete();
},
},
created() {
this.initialize();
},
methods: {
updateClipboard(newClip) {
navigator.clipboard.writeText(newClip).then(
function() {
console.log("Copied", newClip);
},
function() {
console.log("Copy Failed", newClip);
}
);
},
async initialize() {
this.links = await api.signUps.getAll();
},
async deleteUser() {
await api.signUps.deleteToken(this.activeId);
this.initialize();
},
editItem(item) {
this.editedIndex = this.links.indexOf(item);
this.editedItem = Object.assign({}, item);
this.dialog = true;
},
deleteItem(item) {
this.activeId = item.token;
this.activeName = item.name;
this.editedIndex = this.links.indexOf(item);
this.editedItem = Object.assign({}, item);
this.$refs.deleteUserDialog.open();
},
deleteItemConfirm() {
this.links.splice(this.editedIndex, 1);
this.closeDelete();
},
close() {
this.dialog = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
closeDelete() {
this.dialogDelete = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
async save() {
if (this.editedIndex > -1) {
api.links.update(this.editedItem);
this.close();
} else if (this.$refs.newUser.validate()) {
api.signUps.createToken({
name: this.editedItem.name,
admin: this.editedItem.admin,
});
this.close();
}
await this.initialize();
},
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,252 @@
<template>
<v-card outlined class="mt-n1">
<Confirmation
ref="deleteUserDialog"
title="Confirm User Deletion"
:message="
`Are you sure you want to delete the user <b>${activeName} ID: ${activeId}<b/>`
"
icon="mdi-alert"
@confirm="deleteUser"
:width="450"
@close="closeDelete"
/>
<v-toolbar flat>
<v-icon large color="accent" class="mr-1">
mdi-account
</v-icon>
<v-toolbar-title class="headine">
Users
</v-toolbar-title>
<v-spacer> </v-spacer>
<v-dialog v-model="dialog" max-width="600px">
<template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on">
Create User
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-account
</v-icon>
<v-toolbar-title class="headline">
{{ formTitle }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-title class="headline">
User ID: {{ editedItem.id }}
</v-toolbar-title>
</v-app-bar>
<v-card-text>
<v-form ref="newUser">
<v-row>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.fullName"
label="Full Name"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.email"
label="Email"
:rules="[existsRule, emailRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.family"
label="Family Group"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword">
<v-text-field
v-model="editedItem.password"
label="User Password"
:rules="[existsRule, minRule]"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3">
<v-switch v-model="editedItem.admin" label="Admin"></v-switch>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close">
Cancel
</v-btn>
<v-btn color="primary" @click="save">
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-toolbar>
<v-divider></v-divider>
<v-card-text>
<v-data-table :headers="headers" :items="users" sort-by="calories">
<template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
mdi-delete
</v-icon>
Delete
</v-btn>
<v-btn small color="success" @click="editItem(item)">
<v-icon small left class="mr-2">
mdi-pencil
</v-icon>
Edit
</v-btn>
</template>
<template v-slot:item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template v-slot:no-data>
<v-btn color="primary" @click="initialize">
Reset
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script>
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
import { validators } from "@/mixins/validators";
export default {
components: { Confirmation },
mixins: [validators],
data: () => ({
dialog: false,
activeId: null,
activeName: null,
headers: [
{
text: "User ID",
align: "start",
sortable: false,
value: "id",
},
{ text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" },
{ text: "Family", value: "family" },
{ text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
users: [],
editedIndex: -1,
editedItem: {
id: 0,
fullName: "",
password: "",
email: "",
family: "",
admin: false,
},
defaultItem: {
id: 0,
fullName: "",
password: "",
email: "",
family: "",
admin: false,
},
}),
computed: {
formTitle() {
return this.editedIndex === -1 ? "New User" : "Edit User";
},
showPassword() {
return this.editedIndex === -1 ? true : false;
},
},
watch: {
dialog(val) {
val || this.close();
},
dialogDelete(val) {
val || this.closeDelete();
},
},
created() {
this.initialize();
},
methods: {
async initialize() {
this.users = await api.users.allUsers();
},
async deleteUser() {
await api.users.delete(this.activeId);
this.initialize();
},
editItem(item) {
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.dialog = true;
},
deleteItem(item) {
this.activeId = item.id;
this.activeName = item.fullName;
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.$refs.deleteUserDialog.open();
},
deleteItemConfirm() {
this.users.splice(this.editedIndex, 1);
this.closeDelete();
},
close() {
this.dialog = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
closeDelete() {
this.dialogDelete = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
async save() {
if (this.editedIndex > -1) {
api.users.update(this.editedItem);
this.close();
} else if (this.$refs.newUser.validate()) {
api.users.create(this.editedItem);
this.close();
}
await this.initialize();
},
},
};
</script>
<style>
</style>

View file

@ -1,9 +1,22 @@
<template>
<div>
<v-btn text color="info" @click="dialog = true"> {{$t('general.new')}} </v-btn>
<v-dialog v-model="dialog" width="400">
<v-btn text color="info" @click="dialog = true">
{{ $t("settings.add-a-new-theme") }}
</v-btn>
<v-dialog v-model="dialog" width="500">
<v-card>
<v-card-title> {{$t('settings.add-a-new-theme')}} </v-card-title>
<v-app-bar dense dark color="primary mb-2">
<v-icon large left class="mt-1">
mdi-format-color-fill
</v-icon>
<v-toolbar-title class="headline">
{{ $t("settings.add-a-new-theme") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-title> </v-card-title>
<v-card-text>
<v-text-field
:label="$t('settings.theme.theme-name')"
@ -13,9 +26,11 @@
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="dialog = false"> {{$t('general.cancel')}} </v-btn>
<v-btn color="grey" text @click="dialog = false">
{{ $t("general.cancel") }}
</v-btn>
<v-btn color="success" text @click="Select" :disabled="!themeName">
{{$t('general.create')}}
{{ $t("general.create") }}
</v-btn>
</v-card-actions>
</v-card>
@ -34,7 +49,8 @@ export default {
dialog: false,
themeName: "",
rules: {
required: (val) => !!val || this.$t("settings.theme.theme-name-is-required"),
required: val =>
!!val || this.$t("settings.theme.theme-name-is-required"),
},
};
},

View file

@ -0,0 +1,84 @@
<template>
<div>
<Confirmation
:title="$t('settings.theme.delete-theme')"
:message="$t('settings.theme.are-you-sure-you-want-to-delete-this-theme')"
color="error"
icon="mdi-alert-circle"
ref="deleteThemeConfirm"
v-on:confirm="deleteSelectedTheme()"
/>
<v-card flat outlined class="ma-2">
<v-card-text class="mb-n5 mt-n2">
<h3>{{ theme.name }} {{ current ? "(Current)" : "" }}</h3>
</v-card-text>
<v-card-text>
<v-row dense>
<v-card
v-for="(color, index) in theme.colors"
:key="index"
class="mx-1"
height="34"
width="36"
:color="color"
>
</v-card>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-btn text color="error" @click="confirmDelete"> Delete </v-btn>
<v-spacer></v-spacer>
<!-- <v-btn text color="accent" @click="editTheme">Edit</v-btn> -->
<v-btn text color="success" @click="saveThemes">Apply</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script>
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
const DELETE_EVENT = "delete";
const APPLY_EVENT = "apply";
const EDIT_EVENT = "edit";
export default {
components: {
Confirmation,
},
props: {
theme: Object,
current: {
default: false,
},
},
methods: {
confirmDelete() {
if (this.theme.name === "default") {
// Notify User Can't Delete Default
} else if (this.theme !== {}) {
this.$refs.deleteThemeConfirm.open();
}
},
async deleteSelectedTheme() {
//Delete Theme from DB
await api.themes.delete(this.theme.name);
//Get the new list of available from DB
this.availableThemes = await api.themes.requestAll();
this.$emit(DELETE_EVENT);
},
async saveThemes() {
this.$store.commit("setTheme", this.theme);
this.$emit(APPLY_EVENT, this.theme);
},
editTheme() {
this.$emit(EDIT_EVENT);
},
},
};
</script>
<style>
</style>

View file

@ -1,7 +1,7 @@
<template>
<v-card width="500px">
<v-divider></v-divider>
<v-app-bar dark color="primary" class="mt-n1 mb-4">
<v-app-bar dark color="primary" class="mt-n1 mb-2">
<v-icon large left v-if="!loading">
mdi-account
</v-icon>
@ -14,10 +14,8 @@
>
</v-progress-circular>
<v-toolbar-title class="headline"> Login </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
<v-form>
<v-text-field
@ -45,6 +43,8 @@
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
></v-text-field>
</v-form>
<v-card-actions>
<v-btn
v-if="options.isLoggingIn"
@click.prevent="login"
@ -54,7 +54,7 @@
type="submit"
>{{ $t("login.sign-in") }}</v-btn
>
</v-form>
</v-card-actions>
<v-alert v-if="error" outlined class="mt-3 mb-0" type="error">
Could Not Validate Credentials
</v-alert>

View file

@ -0,0 +1,139 @@
<template>
<v-card width="500px">
<v-divider></v-divider>
<v-app-bar dark color="primary" class="mt-n1">
<v-icon large left v-if="!loading">
mdi-account
</v-icon>
<v-progress-circular
v-else
indeterminate
color="white"
large
class="mr-2"
>
</v-progress-circular>
<v-toolbar-title class="headline"> Sign Up </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
Welcome to Mealie! To become a user of this instance you are required to
have a valid invitation link. If you haven't recieved an invitation you
are unable to sign-up. To recieve a link, contact the sites administrator.
<v-divider class="mt-3"></v-divider>
<v-form>
<v-text-field
v-model="user.name"
light="light"
prepend-icon="mdi-account"
validate-on-blur
label="Display Name"
type="email"
></v-text-field>
<v-text-field
v-model="user.email"
light="light"
prepend-icon="mdi-email"
validate-on-blur
:label="$t('login.email')"
type="email"
></v-text-field>
<v-text-field
v-model="user.password"
light="light"
class="mb-2s"
prepend-icon="mdi-lock"
:label="$t('login.password')"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
></v-text-field>
<v-text-field
v-model="user.passwordConfirm"
light="light"
class="mb-2s"
prepend-icon="mdi-lock"
:label="$t('login.password')"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
></v-text-field>
</v-form>
<v-card-actions>
<v-btn
v-if="options.isLoggingIn"
@click.prevent="signUp"
dark
color="primary"
block="block"
type="submit"
>
Sign Up
</v-btn>
</v-card-actions>
<v-alert dense v-if="error" outlined class="mt-3 mb-0" type="error">
Error Signing Up
</v-alert>
</v-card-text>
</v-card>
</template>
<script>
import api from "@/api";
export default {
data() {
return {
loading: false,
error: false,
showPassword: false,
user: {
name: "",
email: "",
password: "",
passwordConfirm: "",
},
options: {
isLoggingIn: true,
},
};
},
mounted() {
this.clear();
},
computed: {
token() {
return this.$route.params.token;
},
},
methods: {
clear() {
this.user = {
name: "",
email: "",
password: "",
passwordConfirm: "",
};
},
async signUp() {
this.loading = true;
this.error = false;
const userData = {
fullName: this.user.name,
email: this.user.email,
family: "public",
password: this.user.password,
admin: false,
};
await api.signUps.createUser(this.token, userData);
this.$emit("user-created");
this.loading = false;
},
},
};
</script>
<style>
</style>

View file

@ -65,7 +65,7 @@
</v-row>
</v-card-text>
<v-card-text>
<v-card-text v-if="startDate">
<MealPlanCard v-model="meals" />
</v-card-text>
<v-row align="center" justify="end">
@ -133,6 +133,7 @@ export default {
let dateDif = (endDate - startDate) / (1000 * 3600 * 24) + 1;
if (dateDif < 1) {
return null;
}
@ -182,9 +183,9 @@ export default {
};
await api.mealPlans.create(mealBody);
this.$emit("created");
this.meals = [];
this.startDate = null;
this.endDate = null;
this.meals = [];
},
getImage(image) {

View file

@ -1,17 +1,17 @@
<template>
<v-card :to="`/recipe/${slug}`" max-height="125">
<v-card hover :to="`/recipe/${slug}`" max-height="125">
<v-list-item>
<v-list-item-avatar rounded size="125" class="mt-0 ml-n4">
<v-img :src="getImage(image)"> </v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-content class="align-self-start">
<v-list-item-title>
{{ name }}
</v-list-item-title>
<v-rating length="5" size="16" dense :value="rating"></v-rating>
<div class="text">
<v-list-item-action-text>
{{ description | truncate(50) }}
{{ description | truncate(115) }}
</v-list-item-action-text>
</div>
</v-list-item-content>
@ -59,4 +59,8 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
}
.text-top {
align-self: start !important;
}
</style>

View file

@ -4,6 +4,7 @@
class="custom-transparent d-flex justify-start align-center text-center "
tile
:width="`${timeCardWidth}`"
height="55"
v-if="totalTime || prepTime || performTime"
>
<v-card flat color="rgb(255, 0, 0, 0.0)">

View file

@ -1,249 +1,56 @@
<template>
<v-data-table
:headers="headers"
:items="users"
sort-by="calories"
class="elevation-1"
>
<template v-slot:top>
<v-toolbar flat>
<v-toolbar-title>Mealie Users</v-toolbar-title>
<v-divider class="mx-4" inset vertical></v-divider>
<v-spacer></v-spacer>
<v-dialog v-model="dialog" max-width="600px">
<template v-slot:activator="{ on, attrs }">
<v-btn color="primary" dark class="mb-2" v-bind="attrs" v-on="on">
Create User
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
mdi-account
</v-icon>
<div>
<v-card flat>
<v-tabs
v-model="tab"
background-color="primary"
centered
dark
icons-and-text
>
<v-tabs-slider></v-tabs-slider>
<v-toolbar-title class="headline">
{{ formTitle }}
</v-toolbar-title>
<v-tab>
Users
<v-icon>mdi-account</v-icon>
</v-tab>
<v-spacer></v-spacer>
<v-toolbar-title class="headline">
User ID: {{ editedItem.id }}
</v-toolbar-title>
</v-app-bar>
<v-tab>
Sign-Up Links
<v-icon>mdi-account-plus-outline</v-icon>
</v-tab>
<v-card-text>
<v-form ref="newUser">
<v-row>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.fullName"
label="Full Name"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.email"
label="Email"
:rules="[existsRule, emailRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.family"
label="Family Group"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword">
<v-text-field
v-model="editedItem.password"
label="User Password"
:rules="[existsRule, minRule]"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3">
<v-switch
v-model="editedItem.admin"
label="Admin"
></v-switch>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-tab>
Groups
<v-icon>mdi-account-group</v-icon>
</v-tab>
</v-tabs>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close">
Cancel
</v-btn>
<v-btn color="primary" @click="save">
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<Confirmation
ref="deleteUserDialog"
title="Confirm User Deletion"
:message="
`Are you sure you want to delete the user <b>${activeName} ID: ${activeId}<b/>`
"
icon="mdi-alert"
@confirm="deleteUser"
:width="450"
@close="closeDelete"
/>
</v-toolbar>
</template>
<template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
mdi-delete
</v-icon>
Delete
</v-btn>
<v-btn small color="success" @click="editItem(item)">
<v-icon small left class="mr-2">
mdi-pencil
</v-icon>
Edit
</v-btn>
</template>
<template v-slot:item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template v-slot:no-data>
<v-btn color="primary" @click="initialize">
Reset
</v-btn>
</template>
</v-data-table>
<v-tabs-items v-model="tab" >
<v-tab-item>
<TheUserTable />
</v-tab-item>
<v-tab-item>
<TheSignUpTable />
</v-tab-item>
<v-tab-item>
<TheGroupTable />
</v-tab-item>
</v-tabs-items>
</v-card>
</div>
</template>
<script>
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
import { validators } from "@/mixins/validators";
import TheUserTable from "@/components/Admin/ManageUsers/TheUserTable";
import TheGroupTable from "@/components/Admin/ManageUsers/TheGroupTable";
import TheSignUpTable from "@/components/Admin/ManageUsers/TheSignUpTable";
export default {
components: { Confirmation },
mixins: [validators],
data: () => ({
dialog: false,
activeId: null,
activeName: null,
headers: [
{
text: "User ID",
align: "start",
sortable: false,
value: "id",
},
{ text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" },
{ text: "Family", value: "family" },
{ text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
users: [],
editedIndex: -1,
editedItem: {
id: 0,
fullName: "",
password: "",
email: "",
family: "",
admin: false,
},
defaultItem: {
id: 0,
fullName: "",
password: "",
email: "",
family: "",
admin: false,
},
}),
computed: {
formTitle() {
return this.editedIndex === -1 ? "New User" : "Edit User";
},
showPassword() {
return this.editedIndex === -1 ? true : false;
},
},
watch: {
dialog(val) {
val || this.close();
},
dialogDelete(val) {
val || this.closeDelete();
},
},
created() {
this.initialize();
},
methods: {
async initialize() {
this.users = await api.users.allUsers();
},
async deleteUser() {
await api.users.delete(this.activeId);
this.initialize();
},
editItem(item) {
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.dialog = true;
},
deleteItem(item) {
this.activeId = item.id;
this.activeName = item.fullName;
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.$refs.deleteUserDialog.open();
},
deleteItemConfirm() {
this.users.splice(this.editedIndex, 1);
this.closeDelete();
},
close() {
this.dialog = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
closeDelete() {
this.dialogDelete = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
async save() {
if (this.editedIndex > -1) {
api.users.update(this.editedItem);
this.close();
} else if (this.$refs.newUser.validate()) {
api.users.create(this.editedItem);
this.close();
}
await this.initialize();
},
components: { TheUserTable, TheGroupTable, TheSignUpTable },
data() {
return {
tab: 0,
};
},
};
</script>

View file

@ -56,42 +56,6 @@
}}
</p>
<v-form ref="form" lazy-validation>
<v-row dense align="center">
<v-col cols="12" md="4" sm="3">
<v-select
:label="$t('settings.theme.saved-color-theme')"
:items="availableThemes"
item-text="name"
return-object
v-model="selectedTheme"
@change="themeSelected"
:rules="[v => !!v || $t('settings.theme.theme-is-required')]"
required
>
</v-select>
</v-col>
<v-col>
<v-btn-toggle group class="mt-n5">
<NewThemeDialog @new-theme="appendTheme" class="mt-1" />
<v-btn text color="error" @click="deleteSelectedThemeValidation">
{{ $t("general.delete") }}
</v-btn>
</v-btn-toggle>
<Confirmation
:title="$t('settings.theme.delete-theme')"
:message="
$t('settings.theme.are-you-sure-you-want-to-delete-this-theme')
"
color="error"
icon="mdi-alert-circle"
ref="deleteThemeConfirm"
v-on:confirm="deleteSelectedTheme()"
/>
</v-col>
<v-spacer></v-spacer>
</v-row>
</v-form>
<v-row dense align-content="center" v-if="selectedTheme.colors">
<v-col>
<ColorPickerDialog
@ -138,7 +102,28 @@
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col
cols="12"
sm="12"
md="6"
lg="4"
xl="3"
v-for="theme in availableThemes"
:key="theme.name"
>
<ThemeCard
:theme="theme"
:current="selectedTheme.name == theme.name ? true : false"
@delete="getAllThemes"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<NewThemeDialog @new-theme="appendTheme" class="mt-1" />
<v-spacer></v-spacer>
<v-btn color="success" @click="saveThemes" class="mr-2">
<v-icon left> mdi-content-save </v-icon>
@ -152,59 +137,35 @@
import api from "@/api";
import ColorPickerDialog from "@/components/Admin/Theme/ColorPickerDialog";
import NewThemeDialog from "@/components/Admin/Theme/NewThemeDialog";
import Confirmation from "@/components/UI/Confirmation";
import ThemeCard from "@/components/Admin/Theme/ThemeCard";
export default {
components: {
ColorPickerDialog,
Confirmation,
NewThemeDialog,
ThemeCard,
},
data() {
return {
selectedTheme: {},
selectedDarkMode: "system",
availableThemes: [],
};
},
async mounted() {
this.availableThemes = await api.themes.requestAll();
this.selectedTheme = this.$store.getters.getActiveTheme;
await this.getAllThemes();
this.selectedDarkMode = this.$store.getters.getDarkMode;
},
methods: {
/**
* Open the delete confirmation.
*/
deleteSelectedThemeValidation() {
if (this.$refs.form.validate()) {
if (this.selectedTheme.name === "default") {
// Notify User Can't Delete Default
} else if (this.selectedTheme !== {}) {
this.$refs.deleteThemeConfirm.open();
}
}
computed: {
selectedTheme() {
return this.$store.getters.getActiveTheme;
},
/**
* Delete the selected Theme
*/
async deleteSelectedTheme() {
//Delete Theme from DB
await api.themes.delete(this.selectedTheme.name);
},
//Get the new list of available from DB
methods: {
async getAllThemes() {
this.availableThemes = await api.themes.requestAll();
//Change to default if deleting current theme.
if (
!this.availableThemes.some(
theme => theme.name === this.selectedTheme.name
)
) {
await this.$store.dispatch("resetTheme");
this.selectedTheme = this.$store.getters.getActiveTheme;
}
},
/**
* Create the new Theme and select it.
@ -212,14 +173,8 @@ export default {
async appendTheme(NewThemeDialog) {
await api.themes.create(NewThemeDialog);
this.availableThemes.push(NewThemeDialog);
this.selectedTheme = NewThemeDialog;
this.$store.commit("setTheme", NewThemeDialog);
},
themeSelected() {
//TODO Revamp Theme selection.
//console.log("this.activeTheme", this.selectedTheme);
},
setStoresDarkMode() {
this.$store.commit("setDarkMode", this.selectedDarkMode);
},
@ -227,13 +182,10 @@ export default {
* This will save the current colors and make the selected theme live.
*/
async saveThemes() {
if (this.$refs.form.validate()) {
this.$store.commit("setTheme", this.selectedTheme);
await api.themes.update(
this.selectedTheme.name,
this.selectedTheme.colors
);
}
await api.themes.update(
this.selectedTheme.name,
this.selectedTheme.colors
);
},
},
};

View file

@ -33,7 +33,7 @@
<v-list-item
v-for="(meal, index) in mealplan.meals"
:key="generateKey(meal.slug, index)"
@click="$router.push(`/recipe/${meal.slug}`)"
:to="meal.slug ? `/recipe/${meal.slug}` : null"
>
<v-list-item-avatar
color="primary"
@ -131,8 +131,8 @@ export default {
this.editMealPlan = null;
this.requestMeals();
},
deletePlan(id) {
api.mealPlans.delete(id);
async deletePlan(id) {
await api.mealPlans.delete(id);
this.requestMeals();
},
openShoppingList(id) {

View file

@ -0,0 +1,41 @@
<template>
<v-container fill-height class="text-center">
<v-row>
<v-flex class="d-flex justify-center" width="500px">
<SignUpForm @logged-in="redirectMe" class="ma-1" />
</v-flex>
</v-row>
<v-row></v-row>
</v-container>
</template>
<script>
import SignUpForm from "../components/Login/SignUpForm";
export default {
components: {
SignUpForm,
},
computed: {
viewScale() {
switch (this.$vuetify.breakpoint.name) {
case "xs":
return true;
case "sm":
return true;
default:
return false;
}
},
},
methods: {
redirectMe() {
if (this.$route.query.redirect) {
this.$router.push(this.$route.query.redirect);
} else this.$router.push({ path: "/" });
},
},
};
</script>
<style>
</style>

View file

@ -8,6 +8,7 @@ import CategoryPage from "../pages/CategoryPage";
import MeaplPlanPage from "../pages/MealPlanPage";
import Debug from "../pages/Debug";
import LoginPage from "../pages/LoginPage";
import SignUpPage from "../pages/SignUpPage";
import MealPlanThisWeekPage from "../pages/MealPlanThisWeekPage";
import api from "@/api";
import Admin from "./admin";
@ -25,6 +26,8 @@ export const routes = [
},
{ path: "/mealie", component: HomePage },
{ path: "/login", component: LoginPage },
{ path: "/sign-up", redirect: "/" },
{ path: "/sign-up/:token", component: SignUpPage },
{ path: "/debug", component: Debug },
{ path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipesPage },

View file

@ -4,6 +4,7 @@ from db.db_base import BaseDocument
from db.models.mealplan import MealPlanModel
from db.models.recipe import Category, RecipeModel, Tag
from db.models.settings import SiteSettingsModel
from db.models.sign_up import SignUp
from db.models.theme import SiteThemeModel
from db.models.users import User
@ -71,6 +72,12 @@ class _Users(BaseDocument):
class _SignUps(BaseDocument):
def __init__(self) -> None:
self.primary_key = "token"
self.sql_model = SignUp
class Database:
def __init__(self) -> None:
self.recipes = _Recipes()
@ -80,6 +87,7 @@ class Database:
self.categories = _Categories()
self.tags = _Tags()
self.users = _Users()
self.sign_ups = _SignUps()
db = Database()

View file

@ -3,3 +3,4 @@ from db.models.recipe import *
from db.models.settings import *
from db.models.theme import *
from db.models.users import *
from db.models.sign_up import *

View file

@ -0,0 +1,29 @@
from db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Column, Integer, String, Boolean
class SignUp(SqlAlchemyBase, BaseMixins):
__tablename__ = "sign_ups"
id = Column(Integer, primary_key=True)
token = Column(String, nullable=False, index=True)
name = Column(String, index=True)
admin = Column(Boolean, default=False)
def __init__(
self,
session,
token,
name,
admin,
) -> None:
self.token = token
self.name = name
self.admin = admin
def dict(self):
return {
"id": self.id,
"name": self.name,
"token": self.token,
"admin": self.admin
}

View file

@ -20,4 +20,5 @@ def query_user(user_email: str, session: Session = None) -> UserInDB:
session = session if session else create_session()
user = db.users.get(session, user_email, "email")
session.close()
return UserInDB(**user)
return UserInDB(**user)

View file

@ -3,9 +3,9 @@ from typing import List
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, HTTPException
from schema.snackbar import SnackResponse
from services.meal_services import MealPlan
from sqlalchemy.orm.session import Session
from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
@ -27,6 +27,7 @@ def get_shopping_list(id: str, session: Session = Depends(generate_session)):
ingredients = [
{"name": x.get("name"), "recipeIngredient": x.get("recipeIngredient")}
for x in recipes
if x
]
return ingredients

View file

@ -0,0 +1,86 @@
import uuid
from core.security import get_password_hash
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from routes.deps import manager
from schema.sign_up import SignUpIn, SignUpOut, SignUpToken
from schema.snackbar import SnackResponse
from schema.user import UserIn, UserInDB
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/users/sign-ups", tags=["User Signup"])
@router.get("", response_model=list[SignUpOut])
async def get_all_open_sign_ups(
current_user=Depends(manager),
session: Session = Depends(generate_session),
):
""" Returns a list of open sign up links """
all_sign_ups = db.sign_ups.get_all(session)
return all_sign_ups
@router.post("", response_model=SignUpToken)
async def create_user_sign_up_key(
key_data: SignUpIn,
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
""" Generates a Random Token that a new user can sign up with """
if current_user.admin:
sign_up = {
"token": str(uuid.uuid1().hex),
"name": key_data.name,
"admin": key_data.admin,
}
db_entry = db.sign_ups.create(session, sign_up)
return db_entry
else:
return {"details": "not authorized"}
@router.post("/{token}")
async def create_user_with_token(
token: str,
new_user: UserIn,
session: Session = Depends(generate_session),
):
""" Creates a user with a valid sign up token """
# Validate Token
db_entry = db.sign_ups.get(session, token, limit=1)
if not db_entry:
return {"details": "invalid token"}
# Create User
new_user.admin = db_entry.get("admin")
new_user.password = get_password_hash(new_user.password)
data = db.users.create(session, new_user.dict())
# DeleteToken
db.sign_ups.delete(session, token)
# Respond
return SnackResponse.success(f"User Created: {new_user.full_name}", data)
@router.delete("/{token}")
async def delete_token(
token: str,
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
""" Removed a token from the database """
if current_user.admin:
db.sign_ups.delete(session, token)
return SnackResponse.error("Sign Up Token Deleted")
else:
return {"details", "not authorized"}

View file

@ -1,7 +1,8 @@
from fastapi import APIRouter
from routes.users import auth, crud
from routes.users import auth, crud, sign_up
router = APIRouter()
router.include_router(sign_up.router)
router.include_router(auth.router)
router.include_router(crud.router)

14
mealie/schema/sign_up.py Normal file
View file

@ -0,0 +1,14 @@
from fastapi_camelcase import CamelModel
class SignUpIn(CamelModel):
name: str
admin: bool
class SignUpToken(SignUpIn):
token: str
class SignUpOut(SignUpToken):
id: int

View file

@ -37,8 +37,8 @@ class Cleaner:
recipe_data["recipeInstructions"] = Cleaner.instructions(
recipe_data["recipeInstructions"]
)
recipe_data["image"] = Cleaner.image(recipe_data["image"])
recipe_data["slug"] = slugify(recipe_data["name"])
recipe_data["image"] = Cleaner.image(recipe_data.get("image"))
recipe_data["slug"] = slugify(recipe_data.get("name"))
recipe_data["orgURL"] = url
return recipe_data
@ -50,7 +50,9 @@ class Cleaner:
return cleantext
@staticmethod
def image(image) -> str:
def image(image=None) -> str:
if not image:
return "no image"
if type(image) == list:
return image[0]
elif type(image) == dict: