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' && \
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 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.

View file

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

View file

@ -19,7 +19,6 @@ from sqlalchemy import or_
from sqlalchemy import select
from sqlalchemy import Text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from sqlmodel import and_
from sqlmodel import AutoString
from sqlmodel import Column
@ -116,7 +115,7 @@ async def read_ghost(
if not can_view:
raise HTTPException(status_code=403, detail="You're not authorized to see this ghost")
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)
.where(Ghost.id == ghost_id)
)
@ -189,7 +188,10 @@ async def get_ghosts(
# Retrieve filtered ghosts from database
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_(
Ghost.owner_id == current_user.id,
@ -231,7 +233,7 @@ async def read_users(
current_user: Annotated[User, Depends(get_current_user())],
offset: int = 0,
limit: int = Query(default=100, lte=100),
session: Session = Depends(get_session),
session: AsyncSession = Depends(get_session),
request: Request = None,
):
subquery = (
@ -242,6 +244,6 @@ async def read_users(
)
query = await session.execute(select(Ghost).where(Ghost.id.in_(subquery)).offset(offset).limit(limit))
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", {})
return ghosts

View file

@ -33,7 +33,7 @@ class HtmlJson:
params = list(sig.parameters.values())
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)
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.users import fastapi_users
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 UserCreate
from ghostforge.users import UserRead
@ -25,6 +26,7 @@ from ghostforge.users import web_backend
gf = FastAPI()
gf.mount("/static", StaticFiles(directory="ghostforge/static"), name="static")
gf.include_router(gf_users)
gf.include_router(gf_ghosts)
gf.include_router(gf_pages)
gf.include_router(gf_ethnicities)

View file

@ -88,7 +88,12 @@
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" %}
{% block title %}{{ target_user.username }}{% endblock title %}
{% block content %}
<div class="columns">
<div class="column col-12">
<h1>Words!</h1>
<h1>Manage User</h1>
</div>
</div>
<div class="columns">
<div class="column col-3"></div>
<div class="column col-6">
<div class="column col-4"></div>
<div class="column col-4">
<div class="panel">
<div class="panel-header text-center">
<figure class="avatar avatar-lg"><img src="{{ url_for('static', path='/img/default-avatar.png') }}"
alt="Avatar"></figure>
<div class="panel-title h5 mt-10">Bruce Banner</div>
<div class="panel-subtitle">THE HULK</div>
<div class="panel-title h5 mt-10">{{ target_user.username }}</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>
<nav class="panel-nav">
<ul class="tab tab-block">
<li class="tab-item active"><a href="#panels">Profile</a></li>
<li class="tab-item"><a href="#panels">Files</a></li>
<li class="tab-item"><a href="#panels">Tasks</a></li>
<li class="tab-item active">Profile</li>
</ul>
</nav>
<div class="panel-body">
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">E-mail</div>
<div class="tile-subtitle">bruce.banner@hulk.com</div>
<div class="tile-title text-bold">Username</div>
<div class="tile-subtitle">{{ target_user.username }}</div>
</div>
<div class="tile-action">
<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 class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">Skype</div>
<div class="tile-subtitle">bruce.banner</div>
<div class="tile-title text-bold">E-mail</div>
<div class="tile-subtitle">{{ target_user.email }}</div>
</div>
<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 class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">Location</div>
<div class="tile-subtitle">Dayton, Ohio</div>
<div class="tile-title text-bold">New Password</div>
<div class="tile-subtitle">blocked</div>
</div>
<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>
@ -58,6 +69,6 @@
</div>
</div>
</div>
<div class="column col-3"></div>
<div class="column col-4"></div>
</div>
{% 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 uuid
from typing import Annotated
from typing import List
from typing import Optional
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Form
from fastapi import HTTPException
from fastapi import Query
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse
from fastapi_users import BaseUserManager
from fastapi_users import FastAPIUsers
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 CookieTransport
from fastapi_users.authentication import JWTStrategy
from fastapi_users.exceptions import UserAlreadyExists
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 User
from ghostforge.htmljson import HtmlJson
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()
hj = HtmlJson()
class UserRead(schemas.BaseUser[uuid.UUID]):
@ -30,10 +60,11 @@ class UserRead(schemas.BaseUser[uuid.UUID]):
class UserCreate(schemas.BaseUserCreate):
username: str
email: str
class UserUpdate(schemas.BaseUserUpdate):
username: str
pass
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}")
async def get_user_manager(user_db: SQLModelUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
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:
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,
}