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 auto_https off
admin off
} }
:80 { :80 {

View file

@ -41,12 +41,12 @@ services:
## Env Variables ## Env Variables
| Variables | default | description | | Variables | default | description |
| ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------- | ----------------------------------------------------------------------------------- |
| db_type | sqlite | The database type to be used. Current Options 'sqlite' | | 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. | | 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. | | 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 | | TZ | UTC | You should set your time zone accordingly so the date/time features work correctly |
## Deployed as a Python Application ## Deployed as a Python Application

View file

@ -86,6 +86,8 @@ export default {
search: false, search: false,
}), }),
methods: { methods: {
// For Later!
/** /**
* Checks if 'system' is set for dark mode and then sets the corrisponding value for vuetify * 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 myUtils from "./upload";
import category from "./category"; import category from "./category";
import meta from "./meta"; import meta from "./meta";
import users from "./users" import users from "./users";
import signUps from "./signUps";
export default { export default {
recipes: recipe, recipes: recipe,
@ -20,5 +20,6 @@ export default {
utils: myUtils, utils: myUtils,
categories: category, categories: category,
meta: meta, 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`, token: `${authPrefix}/token`,
}; };
const usersURLs = { const usersURLs = {
users: `${userPrefix}`, users: `${userPrefix}`,
self: `${userPrefix}/self`, 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> <template>
<div> <div>
<v-btn text color="info" @click="dialog = true"> {{$t('general.new')}} </v-btn> <v-btn text color="info" @click="dialog = true">
<v-dialog v-model="dialog" width="400"> {{ $t("settings.add-a-new-theme") }}
</v-btn>
<v-dialog v-model="dialog" width="500">
<v-card> <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-card-text>
<v-text-field <v-text-field
:label="$t('settings.theme.theme-name')" :label="$t('settings.theme.theme-name')"
@ -13,9 +26,11 @@
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <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"> <v-btn color="success" text @click="Select" :disabled="!themeName">
{{$t('general.create')}} {{ $t("general.create") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
@ -34,7 +49,8 @@ export default {
dialog: false, dialog: false,
themeName: "", themeName: "",
rules: { 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> <template>
<v-card width="500px"> <v-card width="500px">
<v-divider></v-divider> <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"> <v-icon large left v-if="!loading">
mdi-account mdi-account
</v-icon> </v-icon>
@ -14,10 +14,8 @@
> >
</v-progress-circular> </v-progress-circular>
<v-toolbar-title class="headline"> Login </v-toolbar-title> <v-toolbar-title class="headline"> Login </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
</v-app-bar> </v-app-bar>
<v-card-text> <v-card-text>
<v-form> <v-form>
<v-text-field <v-text-field
@ -45,6 +43,8 @@
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword" @click:append="showPassword = !showPassword"
></v-text-field> ></v-text-field>
</v-form>
<v-card-actions>
<v-btn <v-btn
v-if="options.isLoggingIn" v-if="options.isLoggingIn"
@click.prevent="login" @click.prevent="login"
@ -54,7 +54,7 @@
type="submit" type="submit"
>{{ $t("login.sign-in") }}</v-btn >{{ $t("login.sign-in") }}</v-btn
> >
</v-form> </v-card-actions>
<v-alert v-if="error" outlined class="mt-3 mb-0" type="error"> <v-alert v-if="error" outlined class="mt-3 mb-0" type="error">
Could Not Validate Credentials Could Not Validate Credentials
</v-alert> </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-row>
</v-card-text> </v-card-text>
<v-card-text> <v-card-text v-if="startDate">
<MealPlanCard v-model="meals" /> <MealPlanCard v-model="meals" />
</v-card-text> </v-card-text>
<v-row align="center" justify="end"> <v-row align="center" justify="end">
@ -133,6 +133,7 @@ export default {
let dateDif = (endDate - startDate) / (1000 * 3600 * 24) + 1; let dateDif = (endDate - startDate) / (1000 * 3600 * 24) + 1;
if (dateDif < 1) { if (dateDif < 1) {
return null; return null;
} }
@ -182,9 +183,9 @@ export default {
}; };
await api.mealPlans.create(mealBody); await api.mealPlans.create(mealBody);
this.$emit("created"); this.$emit("created");
this.meals = [];
this.startDate = null; this.startDate = null;
this.endDate = null; this.endDate = null;
this.meals = [];
}, },
getImage(image) { getImage(image) {

View file

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

View file

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

View file

@ -1,249 +1,56 @@
<template> <template>
<v-data-table <div>
:headers="headers" <v-card flat>
:items="users" <v-tabs
sort-by="calories" v-model="tab"
class="elevation-1" background-color="primary"
> centered
<template v-slot:top> dark
<v-toolbar flat> icons-and-text
<v-toolbar-title>Mealie Users</v-toolbar-title> >
<v-divider class="mx-4" inset vertical></v-divider> <v-tabs-slider></v-tabs-slider>
<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>
<v-toolbar-title class="headline"> <v-tab>
{{ formTitle }} Users
</v-toolbar-title> <v-icon>mdi-account</v-icon>
</v-tab>
<v-spacer></v-spacer> <v-tab>
<v-toolbar-title class="headline"> Sign-Up Links
User ID: {{ editedItem.id }} <v-icon>mdi-account-plus-outline</v-icon>
</v-toolbar-title> </v-tab>
</v-app-bar>
<v-card-text> <v-tab>
<v-form ref="newUser"> Groups
<v-row> <v-icon>mdi-account-group</v-icon>
<v-col cols="12" sm="12" md="6"> </v-tab>
<v-text-field </v-tabs>
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-tabs-items v-model="tab" >
<v-spacer></v-spacer> <v-tab-item>
<v-btn color="grey" text @click="close"> <TheUserTable />
Cancel </v-tab-item>
</v-btn> <v-tab-item>
<v-btn color="primary" @click="save"> <TheSignUpTable />
Save </v-tab-item>
</v-btn> <v-tab-item>
</v-card-actions> <TheGroupTable />
</v-card> </v-tab-item>
</v-dialog> </v-tabs-items>
<Confirmation </v-card>
ref="deleteUserDialog" </div>
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>
</template> </template>
<script> <script>
import Confirmation from "@/components/UI/Confirmation"; import TheUserTable from "@/components/Admin/ManageUsers/TheUserTable";
import api from "@/api"; import TheGroupTable from "@/components/Admin/ManageUsers/TheGroupTable";
import { validators } from "@/mixins/validators"; import TheSignUpTable from "@/components/Admin/ManageUsers/TheSignUpTable";
export default { export default {
components: { Confirmation }, components: { TheUserTable, TheGroupTable, TheSignUpTable },
mixins: [validators], data() {
data: () => ({ return {
dialog: false, tab: 0,
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> </script>

View file

@ -56,42 +56,6 @@
}} }}
</p> </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-row dense align-content="center" v-if="selectedTheme.colors">
<v-col> <v-col>
<ColorPickerDialog <ColorPickerDialog
@ -138,7 +102,28 @@
</v-row> </v-row>
</v-card-text> </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> <v-card-actions>
<NewThemeDialog @new-theme="appendTheme" class="mt-1" />
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="success" @click="saveThemes" class="mr-2"> <v-btn color="success" @click="saveThemes" class="mr-2">
<v-icon left> mdi-content-save </v-icon> <v-icon left> mdi-content-save </v-icon>
@ -152,59 +137,35 @@
import api from "@/api"; import api from "@/api";
import ColorPickerDialog from "@/components/Admin/Theme/ColorPickerDialog"; import ColorPickerDialog from "@/components/Admin/Theme/ColorPickerDialog";
import NewThemeDialog from "@/components/Admin/Theme/NewThemeDialog"; import NewThemeDialog from "@/components/Admin/Theme/NewThemeDialog";
import Confirmation from "@/components/UI/Confirmation"; import ThemeCard from "@/components/Admin/Theme/ThemeCard";
export default { export default {
components: { components: {
ColorPickerDialog, ColorPickerDialog,
Confirmation,
NewThemeDialog, NewThemeDialog,
ThemeCard,
}, },
data() { data() {
return { return {
selectedTheme: {},
selectedDarkMode: "system", selectedDarkMode: "system",
availableThemes: [], availableThemes: [],
}; };
}, },
async mounted() { async mounted() {
this.availableThemes = await api.themes.requestAll(); await this.getAllThemes();
this.selectedTheme = this.$store.getters.getActiveTheme;
this.selectedDarkMode = this.$store.getters.getDarkMode; this.selectedDarkMode = this.$store.getters.getDarkMode;
}, },
methods: { computed: {
/** selectedTheme() {
* Open the delete confirmation. return this.$store.getters.getActiveTheme;
*/
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();
}
}
}, },
/** },
* 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(); 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. * Create the new Theme and select it.
@ -212,14 +173,8 @@ export default {
async appendTheme(NewThemeDialog) { async appendTheme(NewThemeDialog) {
await api.themes.create(NewThemeDialog); await api.themes.create(NewThemeDialog);
this.availableThemes.push(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() { setStoresDarkMode() {
this.$store.commit("setDarkMode", this.selectedDarkMode); this.$store.commit("setDarkMode", this.selectedDarkMode);
}, },
@ -227,13 +182,10 @@ export default {
* This will save the current colors and make the selected theme live. * This will save the current colors and make the selected theme live.
*/ */
async saveThemes() { async saveThemes() {
if (this.$refs.form.validate()) { await api.themes.update(
this.$store.commit("setTheme", this.selectedTheme); this.selectedTheme.name,
await api.themes.update( this.selectedTheme.colors
this.selectedTheme.name, );
this.selectedTheme.colors
);
}
}, },
}, },
}; };

View file

@ -33,7 +33,7 @@
<v-list-item <v-list-item
v-for="(meal, index) in mealplan.meals" v-for="(meal, index) in mealplan.meals"
:key="generateKey(meal.slug, index)" :key="generateKey(meal.slug, index)"
@click="$router.push(`/recipe/${meal.slug}`)" :to="meal.slug ? `/recipe/${meal.slug}` : null"
> >
<v-list-item-avatar <v-list-item-avatar
color="primary" color="primary"
@ -131,8 +131,8 @@ export default {
this.editMealPlan = null; this.editMealPlan = null;
this.requestMeals(); this.requestMeals();
}, },
deletePlan(id) { async deletePlan(id) {
api.mealPlans.delete(id); await api.mealPlans.delete(id);
this.requestMeals(); this.requestMeals();
}, },
openShoppingList(id) { 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 MeaplPlanPage from "../pages/MealPlanPage";
import Debug from "../pages/Debug"; import Debug from "../pages/Debug";
import LoginPage from "../pages/LoginPage"; import LoginPage from "../pages/LoginPage";
import SignUpPage from "../pages/SignUpPage";
import MealPlanThisWeekPage from "../pages/MealPlanThisWeekPage"; import MealPlanThisWeekPage from "../pages/MealPlanThisWeekPage";
import api from "@/api"; import api from "@/api";
import Admin from "./admin"; import Admin from "./admin";
@ -25,6 +26,8 @@ export const routes = [
}, },
{ path: "/mealie", component: HomePage }, { path: "/mealie", component: HomePage },
{ path: "/login", component: LoginPage }, { path: "/login", component: LoginPage },
{ path: "/sign-up", redirect: "/" },
{ path: "/sign-up/:token", component: SignUpPage },
{ path: "/debug", component: Debug }, { path: "/debug", component: Debug },
{ path: "/search", component: SearchPage }, { path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipesPage }, { 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.mealplan import MealPlanModel
from db.models.recipe import Category, RecipeModel, Tag from db.models.recipe import Category, RecipeModel, Tag
from db.models.settings import SiteSettingsModel from db.models.settings import SiteSettingsModel
from db.models.sign_up import SignUp
from db.models.theme import SiteThemeModel from db.models.theme import SiteThemeModel
from db.models.users import User 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: class Database:
def __init__(self) -> None: def __init__(self) -> None:
self.recipes = _Recipes() self.recipes = _Recipes()
@ -80,6 +87,7 @@ class Database:
self.categories = _Categories() self.categories = _Categories()
self.tags = _Tags() self.tags = _Tags()
self.users = _Users() self.users = _Users()
self.sign_ups = _SignUps()
db = Database() db = Database()

View file

@ -3,3 +3,4 @@ from db.models.recipe import *
from db.models.settings import * from db.models.settings import *
from db.models.theme import * from db.models.theme import *
from db.models.users 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() session = session if session else create_session()
user = db.users.get(session, user_email, "email") user = db.users.get(session, user_email, "email")
session.close() 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.database import db
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from schema.snackbar import SnackResponse
from services.meal_services import MealPlan from services.meal_services import MealPlan
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) 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 = [ ingredients = [
{"name": x.get("name"), "recipeIngredient": x.get("recipeIngredient")} {"name": x.get("name"), "recipeIngredient": x.get("recipeIngredient")}
for x in recipes for x in recipes
if x
] ]
return ingredients 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 fastapi import APIRouter
from routes.users import auth, crud from routes.users import auth, crud, sign_up
router = APIRouter() router = APIRouter()
router.include_router(sign_up.router)
router.include_router(auth.router) router.include_router(auth.router)
router.include_router(crud.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"] = Cleaner.instructions(
recipe_data["recipeInstructions"] recipe_data["recipeInstructions"]
) )
recipe_data["image"] = Cleaner.image(recipe_data["image"]) recipe_data["image"] = Cleaner.image(recipe_data.get("image"))
recipe_data["slug"] = slugify(recipe_data["name"]) recipe_data["slug"] = slugify(recipe_data.get("name"))
recipe_data["orgURL"] = url recipe_data["orgURL"] = url
return recipe_data return recipe_data
@ -50,7 +50,9 @@ class Cleaner:
return cleantext return cleantext
@staticmethod @staticmethod
def image(image) -> str: def image(image=None) -> str:
if not image:
return "no image"
if type(image) == list: if type(image) == list:
return image[0] return image[0]
elif type(image) == dict: elif type(image) == dict: