Add user views and some fixes.

This commit is contained in:
Darryl Nixon 2023-05-30 12:43:00 -07:00
parent d41af49512
commit 11679795ab
9 changed files with 319 additions and 40 deletions

View file

@ -27,7 +27,7 @@ cp .env.sample .env && \
PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$PW/" .env' && \ PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$PW/" .env' && \
JWT=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^GHOSTFORGE_JWT_SECRET=.*/GHOSTFORGE_JWT_SECRET=$JWT/" .env'; JWT=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^GHOSTFORGE_JWT_SECRET=.*/GHOSTFORGE_JWT_SECRET=$JWT/" .env';
docker-compose up --detach --build; docker-compose up --detach --build;
docker exec --interactive --tty ghostforge ghostforge_adduser; docker exec --interactive --tty ghostforge ghostforge_adduser <username> <email> --superuser;
``` ```
Follow the prompts to create an administrator user. Assuming you didn't change the default port, browse to [http://localhost:1337/](http://localhost:1337/) to begin using `ghostforge` with your new credentials. Follow the prompts to create an administrator user. Assuming you didn't change the default port, browse to [http://localhost:1337/](http://localhost:1337/) to begin using `ghostforge` with your new credentials.

View file

@ -1,23 +1,17 @@
import argparse import argparse
import asyncio import asyncio
import contextlib
import getpass import getpass
import re import re
from typing import Tuple from typing import Tuple
from fastapi_users.exceptions import UserAlreadyExists from fastapi_users.exceptions import UserAlreadyExists
from ghostforge.db import get_session from ghostforge.users import get_async_session_context
from ghostforge.db import get_user_db from ghostforge.users import get_user_db_context
from ghostforge.users import get_user_manager from ghostforge.users import get_user_manager_context
from ghostforge.users import UserCreate from ghostforge.users import UserCreate
get_async_session_context = contextlib.asynccontextmanager(get_session)
get_user_db_context = contextlib.asynccontextmanager(get_user_db)
get_user_manager_context = contextlib.asynccontextmanager(get_user_manager)
def is_valid_email(email: str) -> bool: def is_valid_email(email: str) -> bool:
email_regex = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" email_regex = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return bool(re.match(email_regex, email)) return bool(re.match(email_regex, email))

View file

@ -19,7 +19,6 @@ from sqlalchemy import or_
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy import Text from sqlalchemy import Text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from sqlmodel import and_ from sqlmodel import and_
from sqlmodel import AutoString from sqlmodel import AutoString
from sqlmodel import Column from sqlmodel import Column
@ -116,7 +115,7 @@ async def read_ghost(
if not can_view: if not can_view:
raise HTTPException(status_code=403, detail="You're not authorized to see this ghost") raise HTTPException(status_code=403, detail="You're not authorized to see this ghost")
result = await session.execute( result = await session.execute(
select(Ghost, User.username.label("owner_username")) select(Ghost, User.username.label("owner_username"), User.id.label("owner_guid"))
.join(User, Ghost.owner_id == User.id) .join(User, Ghost.owner_id == User.id)
.where(Ghost.id == ghost_id) .where(Ghost.id == ghost_id)
) )
@ -189,7 +188,10 @@ async def get_ghosts(
# Retrieve filtered ghosts from database # Retrieve filtered ghosts from database
query = ( query = (
select(Ghost, User.email.label("owner_email")).offset(offset).limit(limit).join(User, Ghost.owner_id == User.id) select(Ghost, User.username.label("owner_username"), User.id.label("owner_guid"))
.offset(offset)
.limit(limit)
.join(User, Ghost.owner_id == User.id)
) )
permission_filter = or_( permission_filter = or_(
Ghost.owner_id == current_user.id, Ghost.owner_id == current_user.id,
@ -231,7 +233,7 @@ async def read_users(
current_user: Annotated[User, Depends(get_current_user())], current_user: Annotated[User, Depends(get_current_user())],
offset: int = 0, offset: int = 0,
limit: int = Query(default=100, lte=100), limit: int = Query(default=100, lte=100),
session: Session = Depends(get_session), session: AsyncSession = Depends(get_session),
request: Request = None, request: Request = None,
): ):
subquery = ( subquery = (
@ -242,6 +244,6 @@ async def read_users(
) )
query = await session.execute(select(Ghost).where(Ghost.id.in_(subquery)).offset(offset).limit(limit)) query = await session.execute(select(Ghost).where(Ghost.id.in_(subquery)).offset(offset).limit(limit))
ghosts = query.scalars().all() ghosts = query.scalars().all()
data = {"ghosts": ghosts, "user": current_user, "crumbs": [("ghosts", "/ghosts")]} data = {"ghosts": ghosts, "user": current_user, "crumbs": [("ghosts", False)]}
request.state.ghostforge = data | getattr(request.state, "ghostforge", {}) request.state.ghostforge = data | getattr(request.state, "ghostforge", {})
return ghosts return ghosts

View file

@ -33,7 +33,7 @@ class HtmlJson:
params = list(sig.parameters.values()) params = list(sig.parameters.values())
request_param = Parameter("request", _ParameterKind.POSITIONAL_OR_KEYWORD, annotation=Request) request_param = Parameter("request", _ParameterKind.POSITIONAL_OR_KEYWORD, annotation=Request)
params.append(request_param) params.insert(0, request_param)
wrapper.__signature__ = sig.replace(parameters=params) wrapper.__signature__ = sig.replace(parameters=params)
def render_template(self, template: str, request: Request, result): def render_template(self, template: str, request: Request, result):

View file

@ -16,6 +16,7 @@ from ghostforge.pages import gf as gf_pages
from ghostforge.templates import templates from ghostforge.templates import templates
from ghostforge.users import fastapi_users from ghostforge.users import fastapi_users
from ghostforge.users import get_current_user from ghostforge.users import get_current_user
from ghostforge.users import gf as gf_users
from ghostforge.users import jwt_backend from ghostforge.users import jwt_backend
from ghostforge.users import UserCreate from ghostforge.users import UserCreate
from ghostforge.users import UserRead from ghostforge.users import UserRead
@ -25,6 +26,7 @@ from ghostforge.users import web_backend
gf = FastAPI() gf = FastAPI()
gf.mount("/static", StaticFiles(directory="ghostforge/static"), name="static") gf.mount("/static", StaticFiles(directory="ghostforge/static"), name="static")
gf.include_router(gf_users)
gf.include_router(gf_ghosts) gf.include_router(gf_ghosts)
gf.include_router(gf_pages) gf.include_router(gf_pages)
gf.include_router(gf_ethnicities) gf.include_router(gf_ethnicities)

View file

@ -88,7 +88,12 @@
return month + '/' + day + '/' + year; return month + '/' + day + '/' + year;
} }
}, },
{ "data": "owner_email" }, {
"data": "owner_username",
"render": function (data, type, row, meta) {
return '<a href="/manage/' + row.owner_guid + '">' + data + '</a>';
}
}
] ]
}); });
}); });

View file

@ -1,55 +1,66 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ target_user.username }}{% endblock title %}
{% block content %} {% block content %}
<div class="columns"> <div class="columns">
<div class="column col-12"> <div class="column col-12">
<h1>Words!</h1> <h1>Manage User</h1>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns">
<div class="column col-3"></div> <div class="column col-4"></div>
<div class="column col-6"> <div class="column col-4">
<div class="panel"> <div class="panel">
<div class="panel-header text-center"> <div class="panel-header text-center">
<figure class="avatar avatar-lg"><img src="{{ url_for('static', path='/img/default-avatar.png') }}" <figure class="avatar avatar-lg"><img src="{{ url_for('static', path='/img/default-avatar.png') }}"
alt="Avatar"></figure> alt="Avatar"></figure>
<div class="panel-title h5 mt-10">Bruce Banner</div> <div class="panel-title h5 mt-10">{{ target_user.username }}</div>
<div class="panel-subtitle">THE HULK</div> <div class="panel-subtitle">
{% if target_user.is_superuser %}
<kbd>Administrator</kbd>
{% else %}
Normal User
{% endif %}
{% if not target_user.is_active %}
<s>Deactivated</s>
{% endif %}
</div>
</div> </div>
<nav class="panel-nav"> <nav class="panel-nav">
<ul class="tab tab-block"> <ul class="tab tab-block">
<li class="tab-item active"><a href="#panels">Profile</a></li> <li class="tab-item active">Profile</li>
<li class="tab-item"><a href="#panels">Files</a></li>
<li class="tab-item"><a href="#panels">Tasks</a></li>
</ul> </ul>
</nav> </nav>
<div class="panel-body"> <div class="panel-body">
<div class="tile tile-centered"> <div class="tile tile-centered">
<div class="tile-content"> <div class="tile-content">
<div class="tile-title text-bold">E-mail</div> <div class="tile-title text-bold">Username</div>
<div class="tile-subtitle">bruce.banner@hulk.com</div> <div class="tile-subtitle">{{ target_user.username }}</div>
</div> </div>
<div class="tile-action"> <div class="tile-action">
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left" <button class="btn btn-link btn-action btn-lg tooltip tooltip-left"
data-tooltip="Edit E-mail"><i class="icon icon-edit"></i></button> data-tooltip="Change Username" id="edit_username"><i class="icon icon-edit"></i></button>
</div> </div>
</div> </div>
<div class="tile tile-centered"> <div class="tile tile-centered">
<div class="tile-content"> <div class="tile-content">
<div class="tile-title text-bold">Skype</div> <div class="tile-title text-bold">E-mail</div>
<div class="tile-subtitle">bruce.banner</div> <div class="tile-subtitle">{{ target_user.email }}</div>
</div> </div>
<div class="tile-action"> <div class="tile-action">
<button class="btn btn-link btn-action btn-lg"><i class="icon icon-edit"></i></button> <button class="btn btn-link btn-action btn-lg tooltip tooltip-left" data-tooltip="Change E-mail"
id="edit_email"><i class="icon icon-edit"></i></button>
</div> </div>
</div> </div>
<div class="tile tile-centered"> <div class="tile tile-centered">
<div class="tile-content"> <div class="tile-content">
<div class="tile-title text-bold">Location</div> <div class="tile-title text-bold">New Password</div>
<div class="tile-subtitle">Dayton, Ohio</div> <div class="tile-subtitle">blocked</div>
</div> </div>
<div class="tile-action"> <div class="tile-action">
<button class="btn btn-link btn-action btn-lg"><i class="icon icon-edit"></i></button> <button class="btn btn-link btn-action btn-lg tooltip tooltip-left"
data-tooltip="Change Password" id="edit_password"><i class="icon icon-edit"></i></button>
</div> </div>
</div> </div>
</div> </div>
@ -58,6 +69,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="column col-3"></div> <div class="column col-4"></div>
</div> </div>
{% endblock content %} {% endblock content %}

View file

@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}Browse Ghosts{% endblock title %}
{% block js %}
<script src="{{ url_for('static', path='/js/jquery-3.7.0.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/colReorder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.searchPanes.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.colReorder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.stateRestore.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/responsive.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/fixedHeader.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/searchBuilder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.fixedHeader.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/searchPanes.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.responsive.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/stateRestore.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.searchBuilder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/jquery.dataTables.min.js') }}"></script>
{% endblock js %}
{% block css %}
<link href="{{ url_for('static', path='/css/datatables.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/colReorder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/dataTables.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/fixedHeader.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/jquery.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/responsive.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/colReorder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/stateRestore.datatables.min.css') }}" rel="stylesheet">
{% endblock css %}
{% block content %}
<div class="columns">
<div class="column col-12">
<h1>Manage Users</h1>
</div>
</div>
<div class="columns">
<div class="column col-12">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Active</th>
<th>Administrator</th>
<th>E-mail</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
{% endblock content %}
{% block bottomjs %}
<script>
$(document).ready(function () {
$('#users-table').DataTable({
"processing": true,
"serverSide": true,
"ajax": {
"url": "/manage",
"type": "POST",
"dataType": "json"
},
"columns": [
{
"data": "User.id",
"render": function (data, type, row, meta) {
return '<a href="/manage/' + data + '">' + data + '</a>';
}
},
{ "data": "User.username" },
{
"data": "User.is_active",
"render": function (data, type, row, meta) {
var icon = data ? '\u2714' : '\u2718';
var color = data ? '32b643' : 'e85600';
return '<span style="color:#' + color + '">' + icon + '</span>';
}
},
{
"data": "User.is_superuser",
"render": function (data, type, row, meta) {
var icon = data ? '\u2714' : '\u2718';
var color = data ? '32b643' : 'e85600';
return '<span style="color:#' + color + ';">' + icon + '</span>';
}
},
{ "data": "User.email" },
]
});
});
</script>
{% endblock bottomjs %}

View file

@ -1,10 +1,18 @@
import contextlib
import os import os
import uuid import uuid
from typing import Annotated
from typing import List
from typing import Optional from typing import Optional
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import Depends from fastapi import Depends
from fastapi import Form
from fastapi import HTTPException
from fastapi import Query
from fastapi import Request from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse
from fastapi_users import BaseUserManager from fastapi_users import BaseUserManager
from fastapi_users import FastAPIUsers from fastapi_users import FastAPIUsers
from fastapi_users import schemas from fastapi_users import schemas
@ -13,15 +21,37 @@ from fastapi_users.authentication import AuthenticationBackend
from fastapi_users.authentication import BearerTransport from fastapi_users.authentication import BearerTransport
from fastapi_users.authentication import CookieTransport from fastapi_users.authentication import CookieTransport
from fastapi_users.authentication import JWTStrategy from fastapi_users.authentication import JWTStrategy
from fastapi_users.exceptions import UserAlreadyExists
from fastapi_users_db_sqlmodel import SQLModelUserDatabase from fastapi_users_db_sqlmodel import SQLModelUserDatabase
from sqlalchemy import asc
from sqlalchemy import desc
from sqlalchemy import func
from sqlalchemy import inspect
from sqlalchemy import or_
from sqlalchemy import select
from sqlalchemy import Text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import AutoString
from ghostforge.db import get_session
from ghostforge.db import get_user_db from ghostforge.db import get_user_db
from ghostforge.db import User from ghostforge.db import User
from ghostforge.htmljson import HtmlJson
SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET") SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET")
async def get_user_manager(user_db: SQLModelUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
get_async_session_context = contextlib.asynccontextmanager(get_session)
get_user_db_context = contextlib.asynccontextmanager(get_user_db)
get_user_manager_context = contextlib.asynccontextmanager(get_user_manager)
gf = APIRouter() gf = APIRouter()
hj = HtmlJson()
class UserRead(schemas.BaseUser[uuid.UUID]): class UserRead(schemas.BaseUser[uuid.UUID]):
@ -30,10 +60,11 @@ class UserRead(schemas.BaseUser[uuid.UUID]):
class UserCreate(schemas.BaseUserCreate): class UserCreate(schemas.BaseUserCreate):
username: str username: str
email: str
class UserUpdate(schemas.BaseUserUpdate): class UserUpdate(schemas.BaseUserUpdate):
username: str pass
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
@ -50,10 +81,6 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
print(f"Verification requested for user {user.id}. Verification token: {token}") print(f"Verification requested for user {user.id}. Verification token: {token}")
async def get_user_manager(user_db: SQLModelUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
cookie_transport = CookieTransport(cookie_httponly=True, cookie_name="ghostforge", cookie_samesite="strict") cookie_transport = CookieTransport(cookie_httponly=True, cookie_name="ghostforge", cookie_samesite="strict")
@ -75,3 +102,142 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [web_backend, jw
def get_current_user(active: bool = True, optional: bool = False) -> User: def get_current_user(active: bool = True, optional: bool = False) -> User:
return fastapi_users.current_user(active=active, optional=optional) return fastapi_users.current_user(active=active, optional=optional)
@gf.get("/manage", response_model=List[UserRead])
@hj.html_or_json("users.html")
async def read_users(
current_user: Annotated[User, Depends(get_current_user())],
offset: int = 0,
limit: int = Query(default=100, lte=100),
session: AsyncSession = Depends(get_session),
request: Request = None,
):
query = await session.execute(select(User).offset(offset).limit(limit))
users = query.scalars().all()
data = {"users": users, "user": current_user, "crumbs": [("settings", False), ("users", False)]}
request.state.ghostforge = data | getattr(request.state, "ghostforge", {})
return users
@gf.post("/manage/new", response_model=UserRead)
async def create_user(
user: UserCreate,
current_user: Annotated[User, Depends(get_current_user())],
session: AsyncSession = Depends(get_session),
):
try:
async with get_async_session_context() as session:
async with get_user_db_context(session) as user_db:
async with get_user_manager_context(user_db) as user_manager:
if not current_user.is_superuser:
user.is_superuser = False
user = await user_manager.create(
UserCreate(
email=user.email,
username=user.username,
password=user.password,
is_superuser=user.is_superuser,
)
)
return UserRead(user)
except UserAlreadyExists:
return {"error": "User exists"}
@gf.get("/manage/{user_guid}", response_model=UserRead)
@hj.html_or_json("user.html")
async def get_user(
user_guid: str,
current_user: Annotated[User, Depends(get_current_user())],
user_manager: UserManager = Depends(get_user_manager),
session: AsyncSession = Depends(get_session),
request: Request = None,
):
async with get_user_db_context(session) as user_db:
async with get_user_manager_context(user_db) as user_manager:
user = await user_manager.get(user_guid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
data = {
"target_user": user,
"user": current_user,
"crumbs": [("settings", False), ("users", "/manage"), (user.id, False)],
}
request.state.ghostforge = data | getattr(request.state, "ghostforge", {})
return user
@gf.put("/manage/{user_guid}", response_model=UserRead)
async def update_user(
user_guid: str,
user_update: UserUpdate,
current_user: Annotated[User, Depends(get_current_user())],
user_manager: UserManager = Depends(get_user_manager),
session: AsyncSession = Depends(get_session),
):
if not current_user.is_superuser and current_user.id != user_guid:
raise HTTPException(status_code=403, detail="No permission to edit this user")
db_user = await user_manager.get(user_guid, session)
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
update_data = user_update.dict(exclude_unset=True)
if "is_superuser" in update_data and not current_user.is_superuser:
del update_data["is_superuser"]
if "password" in update_data:
update_data["hashed_password"] = await user_manager.hash_password(update_data["password"])
del update_data["password"]
await user_manager.update(db_user, session, **update_data)
await session.refresh(db_user)
return db_user
@gf.get("/profile", response_class=HTMLResponse)
async def home(request: Request, current_user=Depends(get_current_user())):
return RedirectResponse(url=f"/manage/{current_user.id}")
@gf.post("/manage")
async def get_users(
current_user: Annotated[User, Depends(get_current_user())],
start: int = Form(None, alias="start"),
length: int = Form(None, alias="length"),
search: Optional[str] = Form(None, alias="search[value]"),
order_col: int = Form(0, alias="order[0][column]"),
order_dir: str = Form("asc", alias="order[0][dir]"),
session: AsyncSession = Depends(get_session),
request: Request = None,
):
offset = start
limit = offset + length
# Get the column name based on the order_col integer value
user_columns = [c.key for c in inspect(User).c]
order_col_name = user_columns[order_col]
# Retrieve filtered users from database
count = select(func.count(User.id))
total_users = (await session.execute(count)).scalar()
query = select(User).offset(offset).limit(limit)
if search:
conditions = []
for col in user_columns:
column_attr = getattr(User, col)
if isinstance(column_attr.type, (AutoString, Text)):
conditions.append(column_attr.ilike(f"%{search}%"))
query = query.where(or_(*conditions))
if order_dir == "asc":
query = query.order_by(asc(getattr(User, order_col_name)))
else:
query = query.order_by(desc(getattr(User, order_col_name)))
users = (await session.execute(query)).all()
return {
"recordsTotal": total_users,
"recordsFiltered": total_users,
"data": users,
}