Feature/authentication (#185)

* 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

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-02-23 15:15:55 -09:00 committed by GitHub
commit f5fa4040bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 1909 additions and 695 deletions

1
.gitignore vendored
View file

@ -153,3 +153,4 @@ node_modules/
mealie/data/debug/last_recipe.json mealie/data/debug/last_recipe.json
*.sqlite *.sqlite
app_data/db/test.db app_data/db/test.db
scratch.py

View file

@ -27,12 +27,12 @@
<v-icon>mdi-magnify</v-icon> <v-icon>mdi-magnify</v-icon>
</v-btn> </v-btn>
<Menu /> <SiteMenu />
<LanguageMenu />
</v-app-bar> </v-app-bar>
<v-main> <v-main>
<v-container> <v-container>
<AddRecipeFab /> <AddRecipeFab />
<SnackBar />
<router-view></router-view> <router-view></router-view>
</v-container> </v-container>
<FlashMessage :position="'right bottom'"></FlashMessage> <FlashMessage :position="'right bottom'"></FlashMessage>
@ -41,19 +41,20 @@
</template> </template>
<script> <script>
import Menu from "./components/UI/Menu"; import SiteMenu from "@/components/UI/SiteMenu";
import SearchBar from "./components/UI/SearchBar"; import SearchBar from "@/components/UI/Search/SearchBar";
import AddRecipeFab from "./components/UI/AddRecipeFab"; import AddRecipeFab from "@/components/UI/AddRecipeFab";
import SnackBar from "./components/UI/SnackBar"; import LanguageMenu from "@/components/UI/LanguageMenu";
import Vuetify from "./plugins/vuetify"; import Vuetify from "./plugins/vuetify";
export default { export default {
name: "App", name: "App",
components: { components: {
Menu, SiteMenu,
AddRecipeFab, AddRecipeFab,
SnackBar,
SearchBar, SearchBar,
LanguageMenu,
}, },
watch: { watch: {
@ -63,7 +64,7 @@ export default {
}, },
created() { created() {
window.addEventListener("keyup", e => { window.addEventListener("keyup", e => {
if (e.key == "/" && !document.activeElement.id.startsWith('input') ) { if (e.key == "/" && !document.activeElement.id.startsWith("input")) {
this.search = !this.search; this.search = !this.search;
} }
}); });

View file

@ -1,23 +0,0 @@
import backup from "./api/backup";
import recipe from "./api/recipe";
import mealplan from "./api/mealplan";
import settings from "./api/settings";
import themes from "./api/themes";
import migration from "./api/migration";
import myUtils from "./api/upload";
import category from "./api/category";
import meta from "./api/meta";
// import api from "@/api";
export default {
recipes: recipe,
backups: backup,
mealPlans: mealplan,
settings: settings,
themes: themes,
migrations: migration,
utils: myUtils,
categories: category,
meta: meta,
};

View file

@ -1,6 +1,11 @@
const baseURL = "/api/"; const baseURL = "/api/";
import axios from "axios"; import axios from "axios";
import utils from "@/utils"; import utils from "@/utils";
import { store } from "../store/store";
axios.defaults.headers.common[
"Authorization"
] = `Bearer ${store.getters.getToken}`;
function processResponse(response) { function processResponse(response) {
try { try {

24
frontend/src/api/index.js Normal file
View file

@ -0,0 +1,24 @@
import backup from "./backup";
import recipe from "./recipe";
import mealplan from "./mealplan";
import settings from "./settings";
import themes from "./themes";
import migration from "./migration";
import myUtils from "./upload";
import category from "./category";
import meta from "./meta";
import users from "./users"
export default {
recipes: recipe,
backups: backup,
mealPlans: mealplan,
settings: settings,
themes: themes,
migrations: migration,
utils: myUtils,
categories: category,
meta: meta,
users: users
};

View file

@ -70,7 +70,7 @@ export default {
router.push(`/`); router.push(`/`);
}, },
async allByKeys(recipeKeys, num = 100) { async allByKeys(recipeKeys, num = 999) {
const response = await apiReq.get(recipeURLs.allRecipes, { const response = await apiReq.get(recipeURLs.allRecipes, {
params: { params: {
keys: recipeKeys, keys: recipeKeys,

View file

@ -33,7 +33,6 @@ export default {
colors: colors, colors: colors,
}; };
let response = await apiReq.put(settingsURLs.updateTheme(themeName), body); let response = await apiReq.put(settingsURLs.updateTheme(themeName), body);
console.log(response.data);
return response.data; return response.data;
}, },

49
frontend/src/api/users.js Normal file
View file

@ -0,0 +1,49 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const authPrefix = baseURL + "auth";
const userPrefix = baseURL + "users";
const authURLs = {
token: `${authPrefix}/token`,
};
const usersURLs = {
users: `${userPrefix}`,
self: `${userPrefix}/self`,
userID: id => `${userPrefix}/${id}`,
};
export default {
async login(formData) {
let response = await apiReq.post(authURLs.token, formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return response;
},
async allUsers() {
let response = await apiReq.get(usersURLs.users);
return response.data;
},
async create(user) {
let response = await apiReq.post(usersURLs.users, user);
return response.data;
},
async self() {
let response = await apiReq.get(usersURLs.self);
return response.data;
},
async byID(id) {
let response = await apiReq.get(usersURLs.userID(id));
return response.data;
},
async update(user) {
let response = await apiReq.put(usersURLs.userID(user.id), user);
return response.data;
},
async delete(id) {
let response = await apiReq.delete(usersURLs.userID(id));
return response.data;
},
};

View file

@ -0,0 +1,139 @@
<template>
<div>
<v-btn
class="mt-9 ml-n1"
fixed
left
bottom
fab
small
color="primary"
@click="showSidebar = !showSidebar"
>
<v-icon>mdi-cog</v-icon></v-btn
>
<v-navigation-drawer
:value="mobile ? showSidebar : true"
v-model="showSidebar"
width="180px"
clipped
app
>
<template v-slot:prepend>
<v-list-item two-line>
<v-list-item-avatar>
<img src="https://randomuser.me/api/portraits/women/81.jpg" />
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>Jane Smith</v-list-item-title>
<v-list-item-subtitle>Admin</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
<v-divider></v-divider>
<v-list nav dense>
<v-list-item
v-for="nav in baseLinks"
:key="nav.title"
link
:to="nav.to"
>
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list>
<v-divider></v-divider>
<v-list nav dense>
<v-list-item
v-for="nav in superLinks"
:key="nav.title"
link
:to="nav.to"
>
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
</div>
</template>
<script>
export default {
data() {
return {
showSidebar: false,
mobile: false,
links: [],
superLinks: [
{
icon: "mdi-cog",
to: "/admin/settings",
title: "Site Settings",
},
{
icon: "mdi-account-group",
to: "/admin/manage-users",
title: "Manage Users",
},
{
icon: "mdi-backup-restore",
to: "/admin/backups",
title: "Backups",
},
{
icon: "mdi-database-import",
to: "/admin/migrations",
title: "Migrations",
},
],
baseLinks: [
{
icon: "mdi-account",
to: "/admin/profile",
title: "Profile",
},
{
icon: "mdi-format-color-fill",
to: "/admin/themes",
title: "Themes",
},
{
icon: "mdi-food",
to: "/admin/meal-planner",
title: "Meal Planner",
},
],
};
},
mounted() {
this.mobile = this.viewScale();
this.showSidebar = !this.viewScale();
},
methods: {
viewScale() {
switch (this.$vuetify.breakpoint.name) {
case "xs":
return true;
case "sm":
return true;
default:
return false;
}
},
},
};
</script>
<style>
</style>

View file

@ -2,10 +2,18 @@
<div class="text-center"> <div class="text-center">
<v-dialog v-model="dialog" width="70%"> <v-dialog v-model="dialog" width="70%">
<v-card> <v-card>
<v-card-title> Import Summary </v-card-title> <v-app-bar dark color="primary mb-2">
<v-card-text> <v-icon large left>
<v-row class="mb-n9"> mdi-import
<v-card flat> </v-icon>
<v-toolbar-title class="headline">
Import Summary
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text class="mb-n4">
<v-row>
<div>
<v-card-text> <v-card-text>
<div> <div>
<h3>Recipes</h3> <h3>Recipes</h3>
@ -17,8 +25,8 @@
Failed: {{ recipeNumbers.failure }} Failed: {{ recipeNumbers.failure }}
</div> </div>
</v-card-text> </v-card-text>
</v-card> </div>
<v-card flat> <div>
<v-card-text> <v-card-text>
<div> <div>
<h3>Themes</h3> <h3>Themes</h3>
@ -30,8 +38,8 @@
Failed: {{ themeNumbers.failure }} Failed: {{ themeNumbers.failure }}
</div> </div>
</v-card-text> </v-card-text>
</v-card> </div>
<v-card flat> <div>
<v-card-text> <v-card-text>
<div> <div>
<h3>Settings</h3> <h3>Settings</h3>
@ -43,7 +51,7 @@
Failed: {{ settingsNumbers.failure }} Failed: {{ settingsNumbers.failure }}
</div> </div>
</v-card-text> </v-card-text>
</v-card> </div>
</v-row> </v-row>
</v-card-text> </v-card-text>
<v-tabs v-model="tab"> <v-tabs v-model="tab">

View file

@ -25,17 +25,19 @@
<v-card-text> <v-card-text>
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-card outlined min-height="250"> <v-card outlined min-height="350px">
<v-card-text class="pt-2 pb-1"> <v-app-bar dark dense color="primary">
<h3>{{$t('settings.homepage.homepage-categories')}}</h3> <v-icon left>
</v-card-text> mdi-home
<v-divider></v-divider> </v-icon>
<v-list
min-height="200" <v-toolbar-title class="headline">
dense Home Page Categories
max-height="200" </v-toolbar-title>
style="overflow:auto"
> <v-spacer></v-spacer>
</v-app-bar>
<v-list height="300" dense style="overflow:auto">
<v-list-item-group> <v-list-item-group>
<draggable <draggable
v-model="homeCategories" v-model="homeCategories"
@ -65,24 +67,19 @@
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-card outlined min-height="250px"> <v-card outlined height="350px">
<v-card-text class="pt-2 pb-1"> <v-app-bar dark dense color="primary">
<h3> <v-icon left>
{{$t('settings.homepage.all-categories')}} mdi-tag
<span> </v-icon>
<v-btn absolute right x-small color="success" icon>
<v-icon>mdi-plus</v-icon></v-btn <v-toolbar-title class="headline">
> All Categories
</span> </v-toolbar-title>
</h3>
</v-card-text> <v-spacer></v-spacer>
<v-divider></v-divider> </v-app-bar>
<v-list <v-list height="300" dense style="overflow:auto">
min-height="200"
dense
max-height="200"
style="overflow:auto"
>
<v-list-item-group> <v-list-item-group>
<draggable <draggable
v-model="categories" v-model="categories"

View file

@ -1,5 +1,5 @@
<template> <template>
<v-card class="my-2" :loading="loading"> <v-card outlined class="my-2" :loading="loading">
<v-card-title> <v-card-title>
{{ title }} {{ title }}
<v-spacer></v-spacer> <v-spacer></v-spacer>

View file

@ -0,0 +1,29 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="500">
<LoginForm @logged-in="dialog = false" />
</v-dialog>
</div>
</template>
<script>
import LoginForm from "./LoginForm";
export default {
components: {
LoginForm,
},
data() {
return {
dialog: false,
};
},
methods: {
open() {
this.dialog = true;
},
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,145 @@
<template>
<div>
<v-card max-width="500px">
<v-divider></v-divider>
<v-app-bar dark color="primary 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"> Login </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
<v-form>
<v-text-field
v-if="!options.isLoggingIn"
v-model="user.name"
light="light"
prepend-icon="person"
:label="$t('general.name')"
></v-text-field>
<v-text-field
v-model="user.email"
light="light"
prepend-icon="mdi-email"
:label="$t('login.email')"
type="email"
></v-text-field>
<v-text-field
v-model="user.password"
light="light"
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-checkbox
class="mb-2 mt-0"
v-if="options.isLoggingIn"
v-model="options.shouldStayLoggedIn"
light="light"
:label="$t('login.stay-logged-in')"
hide-details="hide-details"
></v-checkbox>
<v-btn
v-if="options.isLoggingIn"
@click.prevent="login"
dark
color="primary"
block="block"
type="submit"
>{{ $t("login.sign-in") }}</v-btn
>
<v-btn
v-else
block="block"
type="submit"
@click.prevent="options.isLoggingIn = true"
>{{ $t("login.sign-up") }}</v-btn
>
</v-form>
<v-alert v-if="error" outlined class="mt-3 mb-0" type="error">
Could Not Validate Credentials
</v-alert>
</v-card-text>
<!-- <v-card-actions v-if="options.isLoggingIn" class="card-actions">
<div>
Don't have an account?
</div>
<v-spacer></v-spacer>
<v-btn
color="primary"
light="light"
@click="options.isLoggingIn = false"
>
Sign up
</v-btn>
</v-card-actions> -->
</v-card>
</div>
</template>
<script>
import api from "@/api";
export default {
props: {},
data() {
return {
loading: false,
error: false,
showLogin: false,
showPassword: false,
user: {
email: "",
password: "",
},
options: {
isLoggingIn: true,
},
};
},
mounted() {
this.clear();
},
methods: {
clear() {
this.user = { email: "", password: "" };
},
async login() {
this.loading = true;
this.error = false;
let formData = new FormData();
formData.append("username", this.user.email);
formData.append("password", this.user.password);
let key;
try {
key = await api.users.login(formData);
} catch {
this.error = true;
}
if (key.status != 200) this.error = true;
else {
this.$emit("logged-in");
this.clear();
}
console.log(key);
this.$store.commit("setToken", key.data.access_token)
this.loading = false;
},
},
};
</script>
<style>
</style>

View file

@ -27,7 +27,7 @@
<script> <script>
import utils from "@/utils"; import utils from "@/utils";
import SearchDialog from "../UI/SearchDialog"; import SearchDialog from "../UI/Search/SearchDialog";
export default { export default {
components: { components: {
SearchDialog, SearchDialog,

View file

@ -37,7 +37,7 @@
</template> </template>
<script> <script>
import Confirmation from "./Confirmation"; import Confirmation from "../../components/UI/Confirmation";
export default { export default {
props: { props: {

View file

@ -2,9 +2,25 @@
<div class="text-center"> <div class="text-center">
<v-dialog v-model="addRecipe" width="650" @click:outside="reset"> <v-dialog v-model="addRecipe" width="650" @click:outside="reset">
<v-card :loading="processing"> <v-card :loading="processing">
<v-card-title class="headline" <v-app-bar dark color="primary mb-2">
>{{ $t("new-recipe.from-url") }} <v-icon large left v-if="!processing">
</v-card-title> mdi-link
</v-icon>
<v-progress-circular
v-else
indeterminate
color="white"
large
class="mr-2"
>
</v-progress-circular>
<v-toolbar-title class="headline">
{{ $t("new-recipe.from-url") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text> <v-card-text>
<v-form ref="urlForm"> <v-form ref="urlForm">
@ -14,7 +30,10 @@
required required
validate-on-blur validate-on-blur
autofocus autofocus
class="mt-1"
:rules="[isValidWebUrl]" :rules="[isValidWebUrl]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field> ></v-text-field>
</v-form> </v-form>
@ -30,7 +49,12 @@
<v-btn color="grey" text @click="reset"> <v-btn color="grey" text @click="reset">
{{ $t("general.close") }} {{ $t("general.close") }}
</v-btn> </v-btn>
<v-btn color="success" text @click="createRecipe"> <v-btn
color="success"
text
@click="createRecipe"
:loading="processing"
>
{{ $t("general.submit") }} {{ $t("general.submit") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>

View file

@ -53,7 +53,7 @@
</template> </template>
<script> <script>
import RecipeCard from "./RecipeCard"; import RecipeCard from "../Recipe/RecipeCard";
export default { export default {
components: { components: {
RecipeCard, RecipeCard,

View file

@ -21,14 +21,20 @@
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="grey" text @click="cancel"> {{ $t("general.cancel") }} </v-btn> <v-btn color="grey" text @click="cancel">
<v-btn :color="color" text @click="confirm"> {{ $t("general.confirm") }} </v-btn> {{ $t("general.cancel") }}
</v-btn>
<v-btn :color="color" text @click="confirm">
{{ $t("general.confirm") }}
</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
</template> </template>
<script> <script>
const CLOSE_EVENT = "close";
const OPEN_EVENT = "open";
/** /**
* Confirmation Component used to add a second validaion step to an action. * Confirmation Component used to add a second validaion step to an action.
* @version 1.0.1 * @version 1.0.1
@ -51,7 +57,7 @@ export default {
*/ */
icon: { icon: {
type: String, type: String,
default: "mid-alert-circle" default: "mid-alert-circle",
}, },
/** /**
* Color theme of the component. Chose one of the defined theme colors. * Color theme of the component. Chose one of the defined theme colors.
@ -59,28 +65,35 @@ export default {
*/ */
color: { color: {
type: String, type: String,
default: "error" default: "error",
}, },
/** /**
* Define the max width of the component. * Define the max width of the component.
*/ */
width: { width: {
type: Number, type: Number,
default: 400 default: 400,
}, },
/** /**
* zIndex of the component. * zIndex of the component.
*/ */
zIndex: { zIndex: {
type: Number, type: Number,
default: 200 default: 200,
} },
},
watch: {
dialog() {
if (this.dialog === false) {
this.$emit(CLOSE_EVENT);
} else this.$emit(OPEN_EVENT);
},
}, },
data: () => ({ data: () => ({
/** /**
* Keep state of open or closed * Keep state of open or closed
*/ */
dialog: false dialog: false,
}), }),
methods: { methods: {
/** /**
@ -120,8 +133,8 @@ export default {
//Hide Modal //Hide Modal
this.dialog = false; this.dialog = false;
} },
} },
}; };
</script> </script>

View file

@ -0,0 +1,80 @@
<template>
<div class="text-center">
<LoginDialog ref="loginDialog" />
<v-menu
transition="slide-x-transition"
bottom
right
offset-y
close-delay="200"
>
<template v-slot:activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" icon>
<v-icon>mdi-translate</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item-group v-model="selectedItem" color="primary">
<v-list-item
v-for="(item, i) in allLanguages"
:key="i"
link
@click="setLanguage(item.value)"
>
<v-list-item-content>
<v-list-item-title>
{{ item.name }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-menu>
</div>
</template>
<script>
import LoginDialog from "../Login/LoginDialog";
export default {
components: {
LoginDialog,
},
data: function() {
return {
selectedItem: 0,
items: [
{
name: "English",
value: "en",
},
],
};
},
mounted() {
let active = this.$store.getters.getActiveLang;
this.allLanguages.forEach((element, index) => {
if (element.value === active) {
this.selectedItem = index;
return;
}
});
},
computed: {
allLanguages() {
return this.$store.getters.getAllLangs;
},
},
methods: {
setLanguage(selectedLanguage) {
this.$store.commit("setLang", selectedLanguage);
},
},
};
</script>
<style>
.menu-text {
text-align: left !important;
}
</style>

View file

@ -1,99 +0,0 @@
<template>
<div class="text-center">
<v-btn icon @click="showLogin = true">
<v-icon>mdi-account</v-icon>
</v-btn>
<v-dialog v-model="showLogin" width="500">
<v-flex class="login-form text-xs-center">
<v-card>
<v-card-text>
<v-form>
<v-text-field
v-if="!options.isLoggingIn"
v-model="user.name"
light="light"
prepend-icon="person"
:label="$t('general.name')"
></v-text-field>
<v-text-field
v-model="user.email"
light="light"
prepend-icon="mdi-email"
:label="$t('login.email')"
type="email"
></v-text-field>
<v-text-field
v-model="user.password"
light="light"
prepend-icon="mdi-lock"
:label="$t('login.password')"
type="password"
></v-text-field>
<v-checkbox
class="mb-2 mt-0"
v-if="options.isLoggingIn"
v-model="options.shouldStayLoggedIn"
light="light"
:label="$t('login.stay-logged-in')"
hide-details="hide-details"
></v-checkbox>
<v-btn
v-if="options.isLoggingIn"
@click.prevent="login"
dark
color="primary"
block="block"
type="submit"
>{{ $t("login.sign-in") }}</v-btn
>
<v-btn
v-else
block="block"
type="submit"
@click.prevent="options.isLoggingIn = true"
>{{ $t("login.sign-up") }}</v-btn
>
</v-form>
</v-card-text>
<!-- <v-card-actions v-if="options.isLoggingIn" class="card-actions">
Don't have an account?
<v-btn
color="primary"
light="light"
@click="options.isLoggingIn = false"
>
Sign up
</v-btn>
</v-card-actions> -->
</v-card>
</v-flex>
</v-dialog>
</div>
</template>
<script>
import api from "@/api";
export default {
props: {},
data() {
return {
showLogin: false,
user: {
email: "",
password: "",
},
options: {
isLoggingIn: true,
},
};
},
methods: {
async login() {
let key = await api.login(this.user.email, this.user.password);
},
},
};
</script>
<style>
</style>

View file

@ -43,8 +43,8 @@
</template> </template>
<script> <script>
import SearchBar from "../UI/SearchBar"; import SearchBar from "./SearchBar";
import RecipeCard from "../UI/RecipeCard"; import RecipeCard from "../../Recipe/RecipeCard";
export default { export default {
components: { components: {
SearchBar, SearchBar,

View file

@ -1,27 +0,0 @@
<template>
<v-row>
<v-col cols="2"> </v-col>
<v-col>
<v-expand-transition>
<Search class="search-bar" />
</v-expand-transition>
</v-col>
<v-col cols="2">
<v-btn icon>
<v-icon> mdi-filter </v-icon>
</v-btn>
</v-col>
</v-row>
</template>
<script>
import Search from "./Search";
export default {
components: {
Search,
},
};
</script>
<style>
</style>

View file

@ -1,5 +1,6 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<LoginDialog ref="loginDialog" />
<v-menu <v-menu
transition="slide-x-transition" transition="slide-x-transition"
bottom bottom
@ -15,7 +16,13 @@
</template> </template>
<v-list> <v-list>
<v-list-item v-for="(item, i) in items" :key="i" link :to="item.nav"> <v-list-item
v-for="(item, i) in filteredItems"
:key="i"
link
:to="item.nav ? item.nav : null"
@click="item.login ? openLoginDialog() : null"
>
<v-list-item-icon> <v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon> <v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon> </v-list-item-icon>
@ -31,36 +38,70 @@
</template> </template>
<script> <script>
import LoginDialog from "../Login/LoginDialog";
export default { export default {
components: {
LoginDialog,
},
data: function() { data: function() {
return { return {
items: [ items: [
{
icon: "mdi-account",
title: "Login",
restricted: false,
login: true,
},
{ {
icon: "mdi-calendar-week", icon: "mdi-calendar-week",
title: this.$i18n.t("meal-plan.dinner-this-week"), title: this.$i18n.t("meal-plan.dinner-this-week"),
nav: "/meal-plan/this-week", nav: "/meal-plan/this-week",
restricted: true,
}, },
{ {
icon: "mdi-calendar-today", icon: "mdi-calendar-today",
title: this.$i18n.t("meal-plan.dinner-today"), title: this.$i18n.t("meal-plan.dinner-today"),
nav: "/meal-plan/today", nav: "/meal-plan/today",
restricted: true,
}, },
{ {
icon: "mdi-calendar-multiselect", icon: "mdi-calendar-multiselect",
title: this.$i18n.t("meal-plan.planner"), title: this.$i18n.t("meal-plan.planner"),
nav: "/meal-plan/planner", nav: "/meal-plan/planner",
restricted: true,
},
{
icon: "mdi-account",
title: "Logout",
restricted: true,
nav: "/logout",
}, },
{ {
icon: "mdi-cog", icon: "mdi-cog",
title: this.$i18n.t("general.settings"), title: this.$i18n.t("general.settings"),
nav: "/settings/site", nav: "/admin",
restricted: true,
}, },
], ],
}; };
}, },
mounted() {},
computed: {
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
filteredItems() {
if (this.loggedIn) {
return this.items.filter(x => x.restricted == true);
} else {
return this.items.filter(x => x.restricted == false);
}
},
},
methods: { methods: {
navRouter(route) { openLoginDialog() {
this.$router.push(route); this.$refs.loginDialog.open();
}, },
}, },
}; };

View file

@ -1,41 +0,0 @@
<template>
<div class="text-center">
<v-snackbar :value="active" :timeout="timeout" :color="type">
{{ text }}
<template v-slot:action="{ attrs }">
<v-btn color="white" text v-bind="attrs" @click="close(false)">
{{$t('general.close')}}
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script>
export default {
data: () => ({
snackbar: false,
timeout: -1,
}),
computed: {
text() {
return this.$store.getters.getSnackText;
},
active() {
return this.$store.getters.getSnackActive;
},
type() {
return this.$store.getters.getSnackType;
},
},
methods: {
close(value) {
this.$store.commit("setSnackActive", value);
},
},
};
</script>
<style>
</style>

View file

@ -4,8 +4,9 @@
"take-me-home": "Take me Home" "take-me-home": "Take me Home"
}, },
"new-recipe": { "new-recipe": {
"from-url": "From URL", "from-url": "Import a Recipe",
"recipe-url": "Recipe URL", "recipe-url": "Recipe URL",
"url-form-hint": "Copy and paste a link from your favorite recipe website",
"error-message": "Looks like there was an error parsing the URL. Check the log and debug/last_recipe.json to see what went wrong.", "error-message": "Looks like there was an error parsing the URL. Check the log and debug/last_recipe.json to see what went wrong.",
"bulk-add": "Bulk Add", "bulk-add": "Bulk Add",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list" "paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list"
@ -89,6 +90,8 @@
}, },
"settings": { "settings": {
"general-settings": "General Settings", "general-settings": "General Settings",
"change-password": "Change Password",
"admin-settings": "Admin Settings",
"local-api": "Local API", "local-api": "Local API",
"language": "Language", "language": "Language",
"add-a-new-theme": "Add a New Theme", "add-a-new-theme": "Add a New Theme",

View file

@ -48,11 +48,11 @@
<script> <script>
import api from "@/api"; import api from "@/api";
import SuccessFailureAlert from "../../UI/SuccessFailureAlert"; import SuccessFailureAlert from "@/components/UI/SuccessFailureAlert";
import ImportSummaryDialog from "./ImportSummaryDialog"; import ImportSummaryDialog from "@/components/Admin/Backup/ImportSummaryDialog";
import UploadBtn from "../../UI/UploadBtn"; import UploadBtn from "@/components/UI/UploadBtn";
import AvailableBackupCard from "./AvailableBackupCard"; import AvailableBackupCard from "@/components/Admin/Backup/AvailableBackupCard";
import NewBackupCard from "./NewBackupCard"; import NewBackupCard from "@/components/Admin/Backup/NewBackupCard";
export default { export default {
components: { components: {

View file

@ -0,0 +1,243 @@
<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>
<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-container>
<v-row>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.fullName"
label="Full Name"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.email"
label="Email"
></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"
></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-container>
</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>
<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>
</template>
<script>
import Confirmation from "@/components/UI/Confirmation";
import api from "@/api";
export default {
components: { Confirmation },
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: "",
email: "",
family: "",
admin: false,
},
defaultItem: {
id: 0,
fullName: "",
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.editedIndex);
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) {
console.log("New User", this.editedItem);
api.users.update(this.editedItem);
} else {
api.users.create(this.editedItem);
}
await this.initialize();
this.close();
},
},
};
</script>
<style>
</style>

View file

@ -97,7 +97,7 @@
<script> <script>
import api from "@/api"; import api from "@/api";
import TimePickerDialog from "./TimePickerDialog"; import TimePickerDialog from "@/components/Admin/MealPlanner/TimePickerDialog";
export default { export default {
components: { components: {
TimePickerDialog, TimePickerDialog,

View file

@ -13,35 +13,37 @@
{{ $t("migration.recipe-migration") }} {{ $t("migration.recipe-migration") }}
</v-card-title> </v-card-title>
<v-divider></v-divider> <v-divider></v-divider>
</v-card>
<v-row dense> <v-card-text>
<v-col <v-row dense>
:cols="12" <v-col
:sm="6" :cols="12"
:md="6" :sm="6"
:lg="4" :md="6"
:xl="3" :lg="4"
v-for="migration in migrations" :xl="3"
:key="migration.title" v-for="migration in migrations"
> :key="migration.title"
<MigrationCard >
:title="migration.title" <MigrationCard
:folder="migration.urlVariable" :title="migration.title"
:description="migration.description" :folder="migration.urlVariable"
:available="migration.availableImports" :description="migration.description"
@refresh="getAvailableMigrations" :available="migration.availableImports"
@imported="showReport" @refresh="getAvailableMigrations"
/> @imported="showReport"
</v-col> />
</v-row> </v-col>
</v-row>
</v-card-text>
</v-card>
</div> </div>
</template> </template>
<script> <script>
import MigrationCard from "./MigrationCard"; import MigrationCard from "@/components/Admin/Migration/MigrationCard";
import SuccessFailureAlert from "../../UI/SuccessFailureAlert"; import SuccessFailureAlert from "@/components/UI/SuccessFailureAlert";
import api from "@/api"; import api from "@/api";
export default { export default {
components: { components: {

View file

@ -0,0 +1,92 @@
<template>
<v-card>
<v-card-title class="headline">
<span>
<v-avatar color="accent" size="40" class="mr-2" v-if="!loading">
<img src="https://cdn.vuetifyjs.com/images/john.jpg" alt="John" />
</v-avatar>
<v-progress-circular
v-else
indeterminate
color="primary"
large
class="mr-2"
>
</v-progress-circular>
</span>
Profile
<v-spacer></v-spacer>
User ID: {{ user.id }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-form>
<v-text-field label="Full Name" v-model="user.fullName"> </v-text-field>
<v-text-field label="Email" v-model="user.email"> </v-text-field>
<v-text-field
label="Family"
readonly
v-model="user.family"
persistent-hint
hint="Family groups can only be set by administrators"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn color="accent" class="mr-2">
<v-icon left> mdi-lock </v-icon>
{{ $t("settings.change-password") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn color="success" class="mr-2" @click="updateUser">
<v-icon left> mdi-content-save </v-icon>
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
// import AvatarPicker from '@/components/AvatarPicker'
import api from "@/api";
export default {
pageTitle: "My Profile",
data() {
return {
loading: false,
user: {
fullName: "Change Me",
email: "changeme@email.com",
family: "public",
admin: true,
id: 1,
},
showAvatarPicker: false,
};
},
async mounted() {
this.refreshProfile();
},
methods: {
async refreshProfile() {
this.user = await api.users.self();
},
openAvatarPicker() {
this.showAvatarPicker = true;
},
selectAvatar(avatar) {
this.user.avatar = avatar;
},
async updateUser() {
this.loading = true;
let newKey = await api.users.update(this.user);
this.$store.commit("setToken", newKey.access_token);
this.refreshProfile();
this.loading = false;
},
},
};
</script>

View file

@ -1,7 +1,7 @@
<template> <template>
<v-card> <v-card>
<v-card-title> <v-card-title class="headline">
{{ $t("settings.general-settings") }} {{ $t("settings.admin-settings") }}
<v-spacer></v-spacer> <v-spacer></v-spacer>
<span> <span>
<v-btn class="pt-1" text href="/docs"> <v-btn class="pt-1" text href="/docs">
@ -10,23 +10,7 @@
</v-btn> </v-btn>
</span> </span>
</v-card-title> </v-card-title>
<v-divider></v-divider>
<v-card-text>
<h2 class="mt-1 mb-4">{{ $t("settings.language") }}</h2>
<v-row>
<v-col sm="3">
<v-select
dense
v-model="selectedLang"
:items="langOptions"
item-text="name"
item-value="value"
:label="$t('settings.language')"
>
</v-select>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
<HomePageSettings /> <HomePageSettings />
<v-divider></v-divider> <v-divider></v-divider>
@ -34,7 +18,7 @@
</template> </template>
<script> <script>
import HomePageSettings from "./HomePageSettings"; import HomePageSettings from "@/components/Admin/General/HomePageSettings";
export default { export default {
components: { components: {

View file

@ -150,9 +150,9 @@
<script> <script>
import api from "@/api"; import api from "@/api";
import ColorPickerDialog from "./ColorPickerDialog"; import ColorPickerDialog from "@/components/Admin/Theme/ColorPickerDialog";
import NewThemeDialog from "./NewThemeDialog"; import NewThemeDialog from "@/components/Admin/Theme/NewThemeDialog";
import Confirmation from "../../UI/Confirmation"; import Confirmation from "@/components/UI/Confirmation";
export default { export default {
components: { components: {

View file

@ -0,0 +1,20 @@
<template>
<div>
<AdminSidebar />
<v-slide-x-transition hide-on-leave>
<router-view></router-view>
</v-slide-x-transition>
</div>
</template>
<script>
import AdminSidebar from "../../components/Admin/AdminSidebar";
export default {
components: {
AdminSidebar,
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,19 @@
<template>
<v-row justify="start" height="100%">
<v-col align="center">
<LoginForm />
</v-col>
</v-row>
</template>
<script>
import LoginForm from "../components/Login/LoginForm";
export default {
components: {
LoginForm,
},
};
</script>
<style>
</style>

View file

@ -12,7 +12,7 @@
</v-img> </v-img>
<br v-else /> <br v-else />
<ButtonRow <EditorButtonRow
@json="jsonEditor = true" @json="jsonEditor = true"
@editor="jsonEditor = false" @editor="jsonEditor = false"
@save="createRecipe" @save="createRecipe"
@ -43,12 +43,12 @@ import api from "@/api";
import RecipeEditor from "../components/Recipe/RecipeEditor"; import RecipeEditor from "../components/Recipe/RecipeEditor";
import VJsoneditor from "v-jsoneditor"; import VJsoneditor from "v-jsoneditor";
import ButtonRow from "../components/UI/ButtonRow"; import EditorButtonRow from "../components/Recipe/EditorButtonRow";
export default { export default {
components: { components: {
VJsoneditor, VJsoneditor,
RecipeEditor, RecipeEditor,
ButtonRow, EditorButtonRow,
}, },
data() { data() {
return { return {

View file

@ -13,7 +13,7 @@
:performTime="recipeDetails.performTime" :performTime="recipeDetails.performTime"
/> />
</v-img> </v-img>
<ButtonRow <EditorButtonRow
:open="showIcons" :open="showIcons"
@json="jsonEditor = true" @json="jsonEditor = true"
@editor=" @editor="
@ -62,14 +62,14 @@ import VJsoneditor from "v-jsoneditor";
import RecipeViewer from "../components/Recipe/RecipeViewer"; import RecipeViewer from "../components/Recipe/RecipeViewer";
import RecipeEditor from "../components/Recipe/RecipeEditor"; import RecipeEditor from "../components/Recipe/RecipeEditor";
import RecipeTimeCard from "../components/Recipe/RecipeTimeCard.vue"; import RecipeTimeCard from "../components/Recipe/RecipeTimeCard.vue";
import ButtonRow from "../components/UI/ButtonRow"; import EditorButtonRow from "../components/Recipe/EditorButtonRow";
export default { export default {
components: { components: {
VJsoneditor, VJsoneditor,
RecipeViewer, RecipeViewer,
RecipeEditor, RecipeEditor,
ButtonRow, EditorButtonRow,
RecipeTimeCard, RecipeTimeCard,
}, },
data() { data() {

View file

@ -34,8 +34,8 @@
</template> </template>
<script> <script>
import SearchBar from "../components/UI/SearchBar"; import SearchBar from "../components/UI/Search/SearchBar";
import RecipeCard from "../components/UI/RecipeCard"; import RecipeCard from "../components/Recipe/RecipeCard";
export default { export default {
components: { components: {
SearchBar, SearchBar,

View file

@ -1,90 +0,0 @@
<template>
<v-container>
<v-alert
v-if="newVersion"
color="green"
type="success"
outlined
v-html="
$t('settings.new-version-available', {
aContents:
'target=\'_blank\' href=\'https://github.com/hay-kot/mealie\' class=\'green--text\'',
})
"
>
</v-alert>
<General />
<Theme class="mt-2" />
<Backup class="mt-2" />
<MealPlanner class="mt-2" />
<Migration class="mt-2" />
<p class="text-center my-2">
{{ $t("settings.current") }}
{{ version }} |
{{ $t("settings.latest") }}
{{ latestVersion }}
·
<a href="https://hay-kot.github.io/mealie/" target="_blank">
{{ $t("settings.explore-the-docs") }}
</a>
·
<a
href="https://hay-kot.github.io/mealie/contributors/non-coders/"
target="_blank"
>
{{ $t("settings.contribute") }}
</a>
</p>
</v-container>
</template>
<script>
import Backup from "../components/Settings/Backup";
import General from "../components/Settings/General";
import MealPlanner from "../components/Settings/MealPlanner";
import Theme from "../components/Settings/Theme";
import Migration from "../components/Settings/Migration";
import api from "@/api";
import axios from "axios";
export default {
components: {
Backup,
MealPlanner,
Theme,
Migration,
General,
},
data() {
return {
latestVersion: null,
version: null,
};
},
async mounted() {
this.getVersion();
let versionData = await api.meta.get_version();
this.version = versionData.version;
},
computed: {
newVersion() {
if ((this.latestVersion != null) & (this.latestVersion != this.version)) {
return true;
} else {
return false;
}
},
},
methods: {
async getVersion() {
let response = await axios.get(
"https://api.github.com/repos/hay-kot/mealie/releases/latest"
);
this.latestVersion = response.data.tag_name;
},
},
};
</script>
<style>
</style>

View file

@ -1,40 +0,0 @@
import HomePage from "./pages/HomePage";
import Page404 from "./pages/404Page";
import SearchPage from "./pages/SearchPage";
import RecipePage from "./pages/RecipePage";
import RecipeNewPage from "./pages/RecipeNewPage";
import SettingsPage from "./pages/SettingsPage";
import AllRecipesPage from "./pages/AllRecipesPage";
import CategoryPage from "./pages/CategoryPage";
import MeaplPlanPage from "./pages/MealPlanPage";
import Debug from "./pages/Debug";
import MealPlanThisWeekPage from "./pages/MealPlanThisWeekPage";
import api from "@/api";
export const routes = [
{ path: "/", component: HomePage },
{ path: "/mealie", component: HomePage },
{ path: "/debug", component: Debug },
{ path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipesPage },
{ path: "/recipes/:category", component: CategoryPage },
{ path: "/recipe/:recipe", component: RecipePage },
{ path: "/new/", component: RecipeNewPage },
{ path: "/settings/site", component: SettingsPage },
{ path: "/meal-plan/planner", component: MeaplPlanPage },
{ path: "/meal-plan/this-week", component: MealPlanThisWeekPage },
{
path: "/meal-plan/today",
beforeEnter: async (_to, _from, next) => {
await todaysMealRoute().then(redirect => {
next(redirect);
});
},
},
{ path: "*", component: Page404 },
];
async function todaysMealRoute() {
const response = await api.mealPlans.today();
return "/recipe/" + response.data;
}

View file

@ -0,0 +1,48 @@
import Admin from "@/pages/Admin";
import Backup from "@/pages/Admin/Backup";
import Theme from "@/pages/Admin/Theme";
import MealPlanner from "@/pages/Admin/MealPlanner";
import Migration from "@/pages/Admin/Migration";
import Profile from "@/pages/Admin/Profile";
import ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings";
export default {
path: "/admin",
component: Admin,
children: [
{
path: "",
component: Profile,
},
{
path: "profile",
component: Profile,
},
{
path: "backups",
component: Backup,
},
{
path: "themes",
component: Theme,
},
{
path: "meal-planner",
component: MealPlanner,
},
{
path: "migrations",
component: Migration,
},
{
path: "manage-users",
component: ManageUsers,
},
{
path: "settings",
component: Settings,
},
],
};

View file

@ -0,0 +1,51 @@
import HomePage from "../pages/HomePage";
import Page404 from "../pages/404Page";
import SearchPage from "../pages/SearchPage";
import RecipePage from "../pages/RecipePage";
import RecipeNewPage from "../pages/RecipeNewPage";
import AllRecipesPage from "../pages/AllRecipesPage";
import CategoryPage from "../pages/CategoryPage";
import MeaplPlanPage from "../pages/MealPlanPage";
import Debug from "../pages/Debug";
import LoginPage from "../pages/LoginPage";
import MealPlanThisWeekPage from "../pages/MealPlanThisWeekPage";
import api from "@/api";
import Admin from "./admin";
import { store } from "../store/store";
export const routes = [
{ path: "/", name: "home", component: HomePage },
{
path: "/logout",
beforeEnter: (_to, _from, next) => {
store.commit("setToken", "");
store.commit("setIsLoggedIn", false);
next("/");
},
},
{ path: "/mealie", component: HomePage },
{ path: "/login", component: LoginPage },
{ path: "/debug", component: Debug },
{ path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipesPage },
{ path: "/recipes/:category", component: CategoryPage },
{ path: "/recipe/:recipe", component: RecipePage },
{ path: "/new/", component: RecipeNewPage },
{ path: "/meal-plan/planner", component: MeaplPlanPage },
{ path: "/meal-plan/this-week", component: MealPlanThisWeekPage },
Admin,
{
path: "/meal-plan/today",
beforeEnter: async (_to, _from, next) => {
await todaysMealRoute().then(redirect => {
next(redirect);
});
},
},
{ path: "*", component: Page404 },
];
async function todaysMealRoute() {
const response = await api.mealPlans.today();
return "/recipe/" + response.data;
}

View file

@ -1,5 +1,6 @@
import api from "@/api"; import api from "@/api";
import Vuetify from "../../plugins/vuetify"; import Vuetify from "../../plugins/vuetify";
import axios from "axios";
function inDarkMode(payload) { function inDarkMode(payload) {
let isDark; let isDark;
@ -18,6 +19,8 @@ const state = {
activeTheme: {}, activeTheme: {},
darkMode: "system", darkMode: "system",
isDark: false, isDark: false,
isLoggedIn: false,
token: "",
}; };
const mutations = { const mutations = {
@ -35,6 +38,14 @@ const mutations = {
state.darkMode = payload; state.darkMode = payload;
} }
}, },
setIsLoggedIn(state, payload) {
state.isLoggedIn = payload;
},
setToken(state, payload) {
state.isLoggedIn = true;
axios.defaults.headers.common["Authorization"] = `Bearer ${payload}`;
state.token = payload;
},
}; };
const actions = { const actions = {
@ -47,6 +58,7 @@ const actions = {
} }
}, },
async initTheme({ dispatch, getters }) { async initTheme({ dispatch, getters }) {
//If theme is empty resetTheme //If theme is empty resetTheme
if (Object.keys(getters.getActiveTheme).length === 0) { if (Object.keys(getters.getActiveTheme).length === 0) {
@ -63,6 +75,8 @@ const getters = {
getActiveTheme: state => state.activeTheme, getActiveTheme: state => state.activeTheme,
getDarkMode: state => state.darkMode, getDarkMode: state => state.darkMode,
getIsDark: state => state.isDark, getIsDark: state => state.isDark,
getIsLoggedIn: state => state.isLoggedIn,
getToken: state => state.token,
}; };
export default { export default {

View file

@ -20,11 +20,6 @@ const store = new Vuex.Store({
homePage, homePage,
}, },
state: { state: {
// Home Page Settings
// Snackbar
snackActive: false,
snackText: "",
snackType: "warning",
// All Recipe Data Store // All Recipe Data Store
recentRecipes: [], recentRecipes: [],
@ -33,15 +28,6 @@ const store = new Vuex.Store({
}, },
mutations: { mutations: {
setSnackBar(state, payload) {
state.snackText = payload.text;
state.snackType = payload.type;
state.snackActive = true;
},
setSnackActive(state, payload) {
state.snackActive = payload;
},
setRecentRecipes(state, payload) { setRecentRecipes(state, payload) {
state.recentRecipes = payload; state.recentRecipes = payload;
}, },
@ -68,11 +54,6 @@ const store = new Vuex.Store({
}, },
getters: { getters: {
//
getSnackText: state => state.snackText,
getSnackActive: state => state.snackActive,
getSnackType: state => state.snackType,
getRecentRecipes: state => state.recentRecipes, getRecentRecipes: state => state.recentRecipes,
getMealPlanCategories: state => state.mealPlanCategories, getMealPlanCategories: state => state.mealPlanCategories,
}, },

View file

@ -1,3 +1,4 @@
const path = require("path");
module.exports = { module.exports = {
transpileDependencies: ["vuetify"], transpileDependencies: ["vuetify"],
publicPath: process.env.NODE_ENV === "production" ? "/" : "/", publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
@ -18,4 +19,11 @@ module.exports = {
enableInSFC: true, enableInSFC: true,
}, },
}, },
configureWebpack: {
resolve: {
alias: {
"@": path.resolve("src"),
},
},
},
}; };

View file

@ -2,7 +2,9 @@ import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
# import utils.startup as startup # import utils.startup as startup
from app_config import APP_VERSION, PORT, PRODUCTION, docs_url, redoc_url from core.config import APP_VERSION, PORT, SECRET, docs_url, redoc_url
from db.db_setup import sql_exists
from db.init_db import init_db
from routes import ( from routes import (
backup_routes, backup_routes,
debug_routes, debug_routes,
@ -17,8 +19,8 @@ from routes.recipe import (
recipe_crud_routes, recipe_crud_routes,
tag_routes, tag_routes,
) )
from services.settings_services import default_settings_init from routes.users import users
from utils.logger import logger from fastapi.logger import logger
app = FastAPI( app = FastAPI(
title="Mealie", title="Mealie",
@ -29,16 +31,17 @@ app = FastAPI(
) )
def data_base_first_run():
init_db()
def start_scheduler(): def start_scheduler():
import services.scheduler.scheduled_jobs import services.scheduler.scheduled_jobs
def init_settings():
default_settings_init()
import services.theme_services
def api_routers(): def api_routers():
# Authentication
app.include_router(users.router)
# Recipes # Recipes
app.include_router(all_recipe_routes.router) app.include_router(all_recipe_routes.router)
app.include_router(category_routes.router) app.include_router(category_routes.router)
@ -56,10 +59,11 @@ def api_routers():
app.include_router(debug_routes.router) app.include_router(debug_routes.router)
if not sql_exists:
data_base_first_run()
api_routers() api_routers()
start_scheduler() start_scheduler()
init_settings()
if __name__ == "__main__": if __name__ == "__main__":
logger.info("-----SYSTEM STARTUP-----") logger.info("-----SYSTEM STARTUP-----")

View file

@ -15,9 +15,12 @@ def ensure_dirs():
ENV = CWD.joinpath(".env") ENV = CWD.joinpath(".env")
dotenv.load_dotenv(ENV) dotenv.load_dotenv(ENV)
SECRET = "super-secret-key"
# General # General
APP_VERSION = "v0.3.0" APP_VERSION = "v0.3.0"
DB_VERSION = "v0.2.1" DB_VERSION = "v0.3.0"
PRODUCTION = os.environ.get("ENV") PRODUCTION = os.environ.get("ENV")
PORT = int(os.getenv("mealie_port", 9000)) PORT = int(os.getenv("mealie_port", 9000))
API = os.getenv("api_docs", True) API = os.getenv("api_docs", True)
@ -30,7 +33,7 @@ else:
redoc_url = None redoc_url = None
# Helpful Globals # Helpful Globals
DATA_DIR = CWD.parent.joinpath("app_data") DATA_DIR = CWD.parent.parent.joinpath("app_data")
if PRODUCTION: if PRODUCTION:
DATA_DIR = Path("/app/data") DATA_DIR = Path("/app/data")
@ -59,6 +62,8 @@ REQUIRED_DIRS = [
ensure_dirs() ensure_dirs()
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
# DATABASE ENV # DATABASE ENV
SQLITE_FILE = None SQLITE_FILE = None

29
mealie/core/security.py Normal file
View file

@ -0,0 +1,29 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Compares a plain string to a hashed password
Args:
plain_password (str): raw password string
hashed_password (str): hashed password from the database
Returns:
bool: Returns True if a match return False
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Takes in a raw password and hashes it. Used prior to saving
a new password to the database.
Args:
password (str): Password String
Returns:
str: Hashed Password
"""
return pwd_context.hash(password)

View file

@ -1,10 +1,11 @@
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from db.db_base import BaseDocument from db.db_base import BaseDocument
from db.sql.meal_models import MealPlanModel from db.models.mealplan import MealPlanModel
from db.sql.recipe_models import Category, RecipeModel, Tag from db.models.recipe import Category, RecipeModel, Tag
from db.sql.settings_models import SiteSettingsModel from db.models.settings import SiteSettingsModel
from db.sql.theme_models import SiteThemeModel from db.models.theme import SiteThemeModel
from db.models.users import User
""" """
# TODO # TODO
@ -55,6 +56,12 @@ class _Themes(BaseDocument):
self.sql_model = SiteThemeModel self.sql_model = SiteThemeModel
class _Users(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = User
class Database: class Database:
def __init__(self) -> None: def __init__(self) -> None:
self.recipes = _Recipes() self.recipes = _Recipes()
@ -63,6 +70,7 @@ class Database:
self.themes = _Themes() self.themes = _Themes()
self.categories = _Categories() self.categories = _Categories()
self.tags = _Tags() self.tags = _Tags()
self.users = _Users()
db = Database() db = Database()

View file

@ -3,7 +3,7 @@ from typing import List
from sqlalchemy.orm import load_only from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from db.sql.model_base import SqlAlchemyBase from db.models.model_base import SqlAlchemyBase
class BaseDocument: class BaseDocument:
@ -108,7 +108,10 @@ class BaseDocument:
db_entries = [x.dict() for x in result] db_entries = [x.dict() for x in result]
if limit == 1: if limit == 1:
return db_entries[0] try:
return db_entries[0]
except IndexError:
return None
return db_entries return db_entries
@ -124,9 +127,8 @@ class BaseDocument:
""" """
new_document = self.sql_model(session=session, **document) new_document = self.sql_model(session=session, **document)
session.add(new_document) session.add(new_document)
return_data = new_document.dict()
session.commit() session.commit()
return_data = new_document.dict()
return return_data return return_data
def update(self, session: Session, match_value: str, new_data: str) -> dict: def update(self, session: Session, match_value: str, new_data: str) -> dict:

View file

@ -1,7 +1,7 @@
from app_config import SQLITE_FILE, USE_SQL from core.config import SQLITE_FILE, USE_SQL
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from db.sql.db_session import sql_global_init from db.models.db_session import sql_global_init
sql_exists = True sql_exists = True

63
mealie/db/init_db.py Normal file
View file

@ -0,0 +1,63 @@
from core.security import get_password_hash
from fastapi.logger import logger
from schema.settings import SiteSettings, Webhooks
from sqlalchemy.orm import Session
from sqlalchemy.orm.session import Session
from db.database import db
from db.db_setup import create_session
def init_db(db: Session = None) -> None:
if not db:
db = create_session()
default_settings_init(db)
default_theme_init(db)
default_user_init(db)
db.close()
def default_theme_init(session: Session):
default_theme = {
"name": "default",
"colors": {
"primary": "#E58325",
"accent": "#00457A",
"secondary": "#973542",
"success": "#5AB1BB",
"info": "#4990BA",
"warning": "#FF4081",
"error": "#EF5350",
},
}
try:
db.themes.create(session, default_theme)
logger.info("Generating default theme...")
except:
logger.info("Default Theme Exists.. skipping generation")
def default_settings_init(session: Session):
try:
webhooks = Webhooks()
default_entry = SiteSettings(name="main", webhooks=webhooks)
document = db.settings.create(session, default_entry.dict())
logger.info(f"Created Site Settings: \n {document}")
except:
pass
def default_user_init(session: Session):
default_user = {
"full_name": "Change Me",
"email": "changeme@email.com",
"password": get_password_hash("MyPassword"),
"family": "public",
"admin": True,
}
logger.info("Generating Default User")
db.users.create(session, default_user)

View file

@ -0,0 +1,5 @@
from db.models.mealplan import *
from db.models.recipe import *
from db.models.settings import *
from db.models.theme import *
from db.models.users import *

View file

@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
import sqlalchemy as sa import sqlalchemy as sa
from db.sql.model_base import SqlAlchemyBase from db.models.model_base import SqlAlchemyBase
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@ -18,7 +18,7 @@ def sql_global_init(db_file: Path, check_thread=False):
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
import db.sql._all_models import db.models._all_models
SqlAlchemyBase.metadata.create_all(engine) SqlAlchemyBase.metadata.create_all(engine)

View file

@ -3,7 +3,7 @@ from typing import List
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase from db.models.model_base import BaseMixins, SqlAlchemyBase
class Meal(SqlAlchemyBase): class Meal(SqlAlchemyBase):

View file

@ -4,11 +4,11 @@ from typing import List
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase from db.models.model_base import BaseMixins, SqlAlchemyBase
from slugify import slugify from slugify import slugify
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import validates from sqlalchemy.orm import validates
from utils.logger import logger from fastapi.logger import logger
class ApiExtras(SqlAlchemyBase): class ApiExtras(SqlAlchemyBase):

View file

@ -1,7 +1,6 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase from db.models.model_base import BaseMixins, SqlAlchemyBase
from db.sql.recipe_models import Category
class SiteSettingsModel(SqlAlchemyBase, BaseMixins): class SiteSettingsModel(SqlAlchemyBase, BaseMixins):

View file

@ -1,6 +1,6 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase from db.models.model_base import BaseMixins, SqlAlchemyBase
class SiteThemeModel(SqlAlchemyBase): class SiteThemeModel(SqlAlchemyBase):

44
mealie/db/models/users.py Normal file
View file

@ -0,0 +1,44 @@
from db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Boolean, Column, Integer, String
class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
full_name = Column(String, index=True)
email = Column(String, unique=True, index=True)
password = Column(String)
is_active = Column(Boolean(), default=True)
family = Column(String)
admin = Column(Boolean(), default=False)
def __init__(
self,
session,
full_name,
email,
password,
family="public",
admin=False,
) -> None:
self.full_name = full_name
self.email = email
self.family = family
self.admin = admin
self.password = password
def dict(self):
return {
"id": self.id,
"full_name": self.full_name,
"email": self.email,
"admin": self.admin,
"family": self.family,
"password": self.password,
}
def update(self, full_name, email, family, admin, session=None):
self.full_name = full_name
self.email = email
self.family = family
self.admin = admin

View file

@ -1,4 +0,0 @@
from db.sql.meal_models import *
from db.sql.recipe_models import *
from db.sql.settings_models import *
from db.sql.theme_models import *

View file

View file

@ -1,15 +1,15 @@
import operator import operator
import shutil import shutil
from app_config import BACKUP_DIR, TEMPLATE_DIR from core.config import BACKUP_DIR, TEMPLATE_DIR
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from models.backup_models import BackupJob, ImportJob, Imports, LocalBackup from schema.backup import BackupJob, ImportJob, Imports, LocalBackup
from services.backups.exports import backup_all from services.backups.exports import backup_all
from services.backups.imports import ImportDatabase from services.backups.imports import ImportDatabase
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse from starlette.responses import FileResponse
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/backups", tags=["Backups"]) router = APIRouter(prefix="/api/backups", tags=["Backups"])

View file

@ -1,9 +1,7 @@
import json import json
from app_config import APP_VERSION, DEBUG_DIR from core.config import APP_VERSION, DEBUG_DIR, LOGGER_FILE
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from utils.logger import LOGGER_FILE
router = APIRouter(prefix="/api/debug", tags=["Debug"]) router = APIRouter(prefix="/api/debug", tags=["Debug"])

23
mealie/routes/deps.py Normal file
View file

@ -0,0 +1,23 @@
from core.config import SECRET
from db.database import db
from db.db_setup import create_session
from fastapi_login import LoginManager
from sqlalchemy.orm.session import Session
from schema.user import UserInDB
manager = LoginManager(SECRET, "/api/auth/token")
@manager.user_loader
def query_user(user_email: str, session: Session = None) -> UserInDB:
"""
Get a user from the db
:param user_id: E-Mail of the user
:return: None or the UserInDB object
"""
session = session if session else create_session()
user = db.users.get(session, user_email, "email")
session.close()
return UserInDB(**user)

View file

@ -5,7 +5,7 @@ from db.db_setup import generate_session
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
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 utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])

View file

@ -2,14 +2,14 @@ import operator
import shutil import shutil
from typing import List from typing import List
from app_config import MIGRATION_DIR from core.config import MIGRATION_DIR
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from models.migration_models import MigrationFile, Migrations from schema.migration import MigrationFile, Migrations
from services.migrations.chowdown import chowdown_migrate as chowdow_migrate from services.migrations.chowdown import chowdown_migrate as chowdow_migrate
from services.migrations.nextcloud import migrate as nextcloud_migrate from services.migrations.nextcloud import migrate as nextcloud_migrate
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/migrations", tags=["Migration"]) router = APIRouter(prefix="/api/migrations", tags=["Migration"])

View file

@ -3,7 +3,7 @@ from typing import List, Optional
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, Query from fastapi import APIRouter, Depends, Query
from models.recipe_models import AllRecipeRequest from schema.recipe import AllRecipeRequest
from slugify import slugify from slugify import slugify
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session

View file

@ -1,11 +1,11 @@
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 from fastapi import APIRouter, Depends
from models.category_models import RecipeCategoryResponse from schema.category import RecipeCategoryResponse
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
router = APIRouter( router = APIRouter(
prefix="/api/categories", prefix="/api/categories",
@ -27,7 +27,6 @@ def get_all_recipes_by_category(
return db.categories.get(session, category) return db.categories.get(session, category)
@router.delete("/{category}") @router.delete("/{category}")
async def delete_recipe_category( async def delete_recipe_category(
category: str, session: Session = Depends(generate_session) category: str, session: Session = Depends(generate_session)

View file

@ -2,12 +2,12 @@ from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, Form, HTTPException from fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.logger import logger from fastapi.logger import logger
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from models.recipe_models import RecipeURLIn from schema.recipe import RecipeURLIn
from services.image_services import read_image, write_image from services.image_services import read_image, write_image
from services.recipe_services import Recipe from services.recipe_services import Recipe
from services.scraper.scraper import create_from_url from services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
router = APIRouter( router = APIRouter(
prefix="/api/recipes", prefix="/api/recipes",

View file

@ -2,9 +2,9 @@ from db.database import db
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
router = APIRouter(tags=["Recipes"]) router = APIRouter(tags=["Recipes"])

View file

@ -1,11 +1,10 @@
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 from fastapi import APIRouter, Depends
from models.settings_models import SiteSettings from schema.settings import SiteSettings
from services.settings_services import default_settings_init
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.post_webhooks import post_webhooks from utils.post_webhooks import post_webhooks
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
router = APIRouter(prefix="/api/site-settings", tags=["Settings"]) router = APIRouter(prefix="/api/site-settings", tags=["Settings"])
@ -17,8 +16,7 @@ def get_main_settings(session: Session = Depends(generate_session)):
try: try:
data = db.settings.get(session, "main") data = db.settings.get(session, "main")
except: except:
default_settings_init(session) return
data = db.settings.get(session, "main")
return data return data

View file

@ -1,8 +1,8 @@
from db.db_setup import generate_session from db.db_setup import generate_session
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from models.theme_models import SiteTheme from schema.theme import SiteTheme
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse from schema.snackbar import SnackResponse
from db.database import db from db.database import db
router = APIRouter(prefix="/api", tags=["Themes"]) router = APIRouter(prefix="/api", tags=["Themes"])

View file

View file

@ -0,0 +1,32 @@
from datetime import timedelta
from core.security import verify_password
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_login.exceptions import InvalidCredentialsException
from routes.deps import manager, query_user
from schema.user import UserInDB
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/auth", tags=["Auth"])
@router.post("/token")
def token(
data: OAuth2PasswordRequestForm = Depends(),
session: Session = Depends(generate_session),
):
email = data.username
password = data.password
user: UserInDB = query_user(email, session)
if not user:
raise InvalidCredentialsException # you can also use your own HTTPException
elif not verify_password(password, user.password):
raise InvalidCredentialsException
access_token = manager.create_access_token(
data=dict(sub=email), expires=timedelta(hours=2)
)
return {"access_token": access_token, "token_type": "bearer"}

View file

@ -0,0 +1,85 @@
from datetime import timedelta
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, query_user
from schema.user import UserBase, UserIn, UserInDB, UserOut
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/users", tags=["Users"])
@router.post("", response_model=UserOut, status_code=201)
async def create_user(
new_user: UserIn,
current_user=Depends(manager),
session: Session = Depends(generate_session),
):
""" Returns a list of all user in the Database """
new_user.password = get_password_hash(new_user.password)
data = db.users.create(session, new_user.dict())
return data
@router.get("", response_model=list[UserOut])
async def get_all_users(
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
if current_user.admin:
return db.users.get_all(session)
else:
return {"details": "user not authorized"}
@router.get("/self", response_model=UserOut)
async def get_user_by_id(
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
return current_user.dict()
@router.get("/{id}", response_model=UserOut)
async def get_user_by_id(
id: int,
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
return db.users.get(session, id)
@router.put("/{id}")
async def update_user(
id: int,
new_data: UserBase,
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
if current_user.id == id or current_user.admin:
updated_user = db.users.update(session, id, new_data.dict())
email = updated_user.get("email")
if current_user.id == id:
access_token = manager.create_access_token(
data=dict(sub=email), expires=timedelta(hours=2)
)
return {"access_token": access_token, "token_type": "bearer"}
return
@router.delete("/{id}")
async def delete_user(
id: int,
current_user: UserInDB = Depends(manager),
session: Session = Depends(generate_session),
):
""" Removes a user from the database. Must be the current user or a super user"""
if current_user.id == id or current_user.admin:
return db.users.delete(session, id)

View file

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

32
mealie/schema/user.py Normal file
View file

@ -0,0 +1,32 @@
from typing import Optional
from fastapi_camelcase import CamelModel
# from pydantic import EmailStr
class UserBase(CamelModel):
full_name: Optional[str] = None
email: str
family: str
admin: bool
class Config:
schema_extra = {
"fullName": "Change Me",
"email": "changeme@email.com",
"family": "public",
"admin": "false",
}
class UserIn(UserBase):
password: str
class UserOut(UserBase):
id: int
class UserInDB(UserIn, UserOut):
pass

View file

@ -3,13 +3,13 @@ import shutil
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from app_config import BACKUP_DIR, IMG_DIR, TEMP_DIR, TEMPLATE_DIR from core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR, TEMPLATE_DIR
from db.database import db from db.database import db
from db.db_setup import create_session from db.db_setup import create_session
from jinja2 import Template from jinja2 import Template
from services.meal_services import MealPlan from services.meal_services import MealPlan
from services.recipe_services import Recipe from services.recipe_services import Recipe
from utils.logger import logger from fastapi.logger import logger
class ExportDatabase: class ExportDatabase:

View file

@ -4,13 +4,13 @@ import zipfile
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from app_config import BACKUP_DIR, IMG_DIR, TEMP_DIR from core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR
from db.database import db from db.database import db
from models.import_models import RecipeImport, SettingsImport, ThemeImport from schema.restore import RecipeImport, SettingsImport, ThemeImport
from models.theme_models import SiteTheme from schema.theme import SiteTheme
from services.recipe_services import Recipe from services.recipe_services import Recipe
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from utils.logger import logger from fastapi.logger import logger
class ImportDatabase: class ImportDatabase:

View file

@ -2,8 +2,8 @@ import shutil
from pathlib import Path from pathlib import Path
import requests import requests
from app_config import IMG_DIR from core.config import IMG_DIR
from utils.logger import logger from fastapi.logger import logger
def read_image(recipe_slug: str) -> Path: def read_image(recipe_slug: str) -> Path:

Some files were not shown because too many files have changed in this diff Show more