Add authentication system and admin dashboard

Security Features:
- Added User model with bcrypt password hashing
- Implemented Flask-Login for session management
- Protected all API write operations with @login_required decorator
- Added authentication routes (login/logout)

Admin Dashboard:
- Created comprehensive admin dashboard with statistics
- Profile management interface
- Skills management (add/edit/delete)
- Projects management with full CRUD operations
- Social links management
- Modern responsive UI with Bootstrap 5

New Files:
- models.py: Added User model with bcrypt
- routes/auth.py: Login/logout functionality
- routes/admin.py: Complete admin dashboard with CRUD operations
- templates/auth/login.html: Login page
- templates/admin/base.html: Admin base template
- templates/admin/dashboard.html: Main dashboard
- templates/admin/profile.html: Profile editor
- templates/admin/skills.html: Skills manager
- templates/admin/projects.html: Projects list
- templates/admin/project_form.html: Project editor
- templates/admin/social_links.html: Social links manager

Modified Files:
- app.py: Integrated Flask-Login and bcrypt, registered new blueprints
- requirements.txt: Added Flask-Login, Flask-Bcrypt, bcrypt
- init_db.py: Creates default admin user (admin/admin123)
- routes/api.py: Protected all write operations with authentication

Default Credentials:
- Username: admin
- Password: admin123
- ⚠️ MUST be changed after first login!

Benefits:
- Secure API access with session-based authentication
- User-friendly admin interface for content management
- No need to edit code or database directly
- Bcrypt password hashing for security
- Protected against unauthorized access
This commit is contained in:
Claude
2025-11-13 13:49:36 +00:00
parent c6425235a2
commit aa2c704bfb
15 changed files with 1159 additions and 4 deletions

22
app.py
View File

@@ -4,10 +4,13 @@
# Copyright Hersel Giannella
from flask import Flask, send_from_directory
from flask_login import LoginManager
from config import config
from models import db
from models import db, bcrypt, User
from routes.home import route_home
from routes.api import route_api
from routes.auth import route_auth
from routes.admin import route_admin
app = Flask(
__name__,
@@ -21,8 +24,21 @@ app.config['SQLALCHEMY_DATABASE_URI'] = config.SQLALCHEMY_DATABASE_URI
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = config.SQLALCHEMY_TRACK_MODIFICATIONS
app.config['SQLALCHEMY_ECHO'] = config.SQLALCHEMY_ECHO
# Initialize database
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
# Initialize Flask-Login
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Per accedere a questa pagina devi effettuare il login.'
login_manager.login_message_category = 'warning'
@login_manager.user_loader
def load_user(user_id):
"""Load user for Flask-Login"""
return User.query.get(int(user_id))
# favicon.ico, sitemap.xml and robots.txt
@app.route('/favicon.ico')
@@ -40,6 +56,8 @@ def robots():
# BluePrint Routes
app.register_blueprint(route_home)
app.register_blueprint(route_api)
app.register_blueprint(route_auth)
app.register_blueprint(route_admin)
if __name__ == '__main__':
app.run(debug=config.DEBUG, host=config.APP_HOST, port=config.APP_PORT)

View File

@@ -1,9 +1,9 @@
"""
Database initialization script
Populates the database with initial portfolio data
Populates the database with initial portfolio data and creates default admin user
"""
from app import app
from models import db, Profile, Skill, Project, ProjectTag, SocialLink
from models import db, User, Profile, Skill, Project, ProjectTag, SocialLink
def init_database():
@@ -16,6 +16,15 @@ def init_database():
print("Creating all tables...")
db.create_all()
# Create default admin user
print("Creating default admin user...")
admin = User(
username='admin',
email='admin@hersel.it'
)
admin.set_password('admin123') # CHANGE THIS PASSWORD AFTER FIRST LOGIN!
db.session.add(admin)
# Create profile information
print("Adding profile information...")
profile = Profile(
@@ -169,11 +178,19 @@ def init_database():
db.session.commit()
print("\n✅ Database initialized successfully!")
print(f" - Admin User: 1 record")
print(f" - Profile: 1 record")
print(f" - Skills: {len(skills_data)} records")
print(f" - Projects: 3 records")
print(f" - Project Tags: {len(project1_tags) + len(project2_tags) + len(project3_tags)} records")
print(f" - Social Links: {len(social_links_data)} records")
print("\n" + "="*60)
print("🔐 DEFAULT ADMIN CREDENTIALS")
print("="*60)
print(f" Username: admin")
print(f" Password: admin123")
print(f" ⚠️ CHANGE THIS PASSWORD IMMEDIATELY AFTER FIRST LOGIN!")
print("="*60)
if __name__ == '__main__':

View File

@@ -3,9 +3,43 @@ Database models for Portfolio Application
Uses SQLAlchemy ORM with MariaDB
"""
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from flask_bcrypt import Bcrypt
from datetime import datetime
db = SQLAlchemy()
bcrypt = Bcrypt()
class User(UserMixin, db.Model):
"""Store admin users for authentication"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
def set_password(self, password):
"""Hash password using bcrypt"""
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
"""Verify password against hash"""
return bcrypt.check_password_hash(self.password_hash, password)
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None
}
class Profile(db.Model):

View File

@@ -7,6 +7,11 @@ itsdangerous==2.2.0
MarkupSafe==3.0.2
blinker==1.9.0
# Authentication
Flask-Login==0.6.3
Flask-Bcrypt==1.0.1
bcrypt==4.2.1
# Database - Flask-SQLAlchemy and MariaDB/MySQL driver
Flask-SQLAlchemy==3.1.1
SQLAlchemy==2.0.36

285
routes/admin.py Normal file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright Hersel Giannella
"""
Admin dashboard routes for managing portfolio content
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from models import db, Profile, Skill, Project, ProjectTag, SocialLink
route_admin = Blueprint('admin', __name__, url_prefix='/admin')
@route_admin.route('/')
@login_required
def dashboard():
"""Admin dashboard home"""
# Statistiche
stats = {
'projects': Project.query.count(),
'skills': Skill.query.count(),
'social_links': SocialLink.query.count(),
'published_projects': Project.query.filter_by(is_published=True).count()
}
return render_template('admin/dashboard.html', stats=stats)
# ============================================================================
# PROFILE MANAGEMENT
# ============================================================================
@route_admin.route('/profile')
@login_required
def profile_manage():
"""Manage profile information"""
profile = Profile.query.first()
return render_template('admin/profile.html', profile=profile)
@route_admin.route('/profile/edit', methods=['POST'])
@login_required
def profile_edit():
"""Edit profile information"""
profile = Profile.query.first()
if not profile:
flash('Profilo non trovato.', 'danger')
return redirect(url_for('admin.profile_manage'))
profile.title = request.form.get('title', profile.title)
profile.lead_text = request.form.get('lead_text', profile.lead_text)
profile.description_1 = request.form.get('description_1', profile.description_1)
profile.description_2 = request.form.get('description_2', profile.description_2)
profile.years_experience = int(request.form.get('years_experience', profile.years_experience))
profile.cv_url = request.form.get('cv_url', profile.cv_url)
db.session.commit()
flash('Profilo aggiornato con successo!', 'success')
return redirect(url_for('admin.profile_manage'))
# ============================================================================
# SKILLS MANAGEMENT
# ============================================================================
@route_admin.route('/skills')
@login_required
def skills_manage():
"""Manage skills"""
skills = Skill.query.order_by(Skill.display_order).all()
return render_template('admin/skills.html', skills=skills)
@route_admin.route('/skills/add', methods=['POST'])
@login_required
def skill_add():
"""Add new skill"""
skill = Skill(
name=request.form.get('name'),
icon_class=request.form.get('icon_class'),
category=request.form.get('category'),
display_order=int(request.form.get('display_order', 0)),
is_active=request.form.get('is_active') == 'on'
)
db.session.add(skill)
db.session.commit()
flash('Skill aggiunta con successo!', 'success')
return redirect(url_for('admin.skills_manage'))
@route_admin.route('/skills/<int:skill_id>/edit', methods=['POST'])
@login_required
def skill_edit(skill_id):
"""Edit skill"""
skill = Skill.query.get_or_404(skill_id)
skill.name = request.form.get('name', skill.name)
skill.icon_class = request.form.get('icon_class', skill.icon_class)
skill.category = request.form.get('category', skill.category)
skill.display_order = int(request.form.get('display_order', skill.display_order))
skill.is_active = request.form.get('is_active') == 'on'
db.session.commit()
flash('Skill aggiornata con successo!', 'success')
return redirect(url_for('admin.skills_manage'))
@route_admin.route('/skills/<int:skill_id>/delete', methods=['POST'])
@login_required
def skill_delete(skill_id):
"""Delete skill"""
skill = Skill.query.get_or_404(skill_id)
db.session.delete(skill)
db.session.commit()
flash('Skill eliminata con successo!', 'success')
return redirect(url_for('admin.skills_manage'))
# ============================================================================
# PROJECTS MANAGEMENT
# ============================================================================
@route_admin.route('/projects')
@login_required
def projects_manage():
"""Manage projects"""
projects = Project.query.order_by(Project.display_order).all()
return render_template('admin/projects.html', projects=projects)
@route_admin.route('/projects/add', methods=['GET', 'POST'])
@login_required
def project_add():
"""Add new project"""
if request.method == 'POST':
project = Project(
title=request.form.get('title'),
description=request.form.get('description'),
image_url=request.form.get('image_url'),
demo_url=request.form.get('demo_url'),
github_url=request.form.get('github_url'),
display_order=int(request.form.get('display_order', 0)),
animation_delay=request.form.get('animation_delay', '0s'),
is_published=request.form.get('is_published') == 'on'
)
db.session.add(project)
db.session.flush()
# Aggiungi tags
tags_input = request.form.get('tags', '')
if tags_input:
tags_list = [tag.strip() for tag in tags_input.split(',') if tag.strip()]
for idx, tag_name in enumerate(tags_list):
# Estrai colore se specificato (formato: "Python:bg-primary")
if ':' in tag_name:
tag_name, color = tag_name.split(':', 1)
else:
color = 'bg-primary'
tag = ProjectTag(
project_id=project.id,
name=tag_name.strip(),
color_class=color.strip(),
display_order=idx
)
db.session.add(tag)
db.session.commit()
flash('Progetto aggiunto con successo!', 'success')
return redirect(url_for('admin.projects_manage'))
return render_template('admin/project_form.html', project=None)
@route_admin.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'])
@login_required
def project_edit(project_id):
"""Edit project"""
project = Project.query.get_or_404(project_id)
if request.method == 'POST':
project.title = request.form.get('title', project.title)
project.description = request.form.get('description', project.description)
project.image_url = request.form.get('image_url', project.image_url)
project.demo_url = request.form.get('demo_url', project.demo_url)
project.github_url = request.form.get('github_url', project.github_url)
project.display_order = int(request.form.get('display_order', project.display_order))
project.animation_delay = request.form.get('animation_delay', project.animation_delay)
project.is_published = request.form.get('is_published') == 'on'
# Aggiorna tags
ProjectTag.query.filter_by(project_id=project.id).delete()
tags_input = request.form.get('tags', '')
if tags_input:
tags_list = [tag.strip() for tag in tags_input.split(',') if tag.strip()]
for idx, tag_name in enumerate(tags_list):
if ':' in tag_name:
tag_name, color = tag_name.split(':', 1)
else:
color = 'bg-primary'
tag = ProjectTag(
project_id=project.id,
name=tag_name.strip(),
color_class=color.strip(),
display_order=idx
)
db.session.add(tag)
db.session.commit()
flash('Progetto aggiornato con successo!', 'success')
return redirect(url_for('admin.projects_manage'))
return render_template('admin/project_form.html', project=project)
@route_admin.route('/projects/<int:project_id>/delete', methods=['POST'])
@login_required
def project_delete(project_id):
"""Delete project"""
project = Project.query.get_or_404(project_id)
db.session.delete(project)
db.session.commit()
flash('Progetto eliminato con successo!', 'success')
return redirect(url_for('admin.projects_manage'))
# ============================================================================
# SOCIAL LINKS MANAGEMENT
# ============================================================================
@route_admin.route('/social-links')
@login_required
def social_links_manage():
"""Manage social links"""
social_links = SocialLink.query.order_by(SocialLink.display_order).all()
return render_template('admin/social_links.html', social_links=social_links)
@route_admin.route('/social-links/add', methods=['POST'])
@login_required
def social_link_add():
"""Add new social link"""
link = SocialLink(
platform_name=request.form.get('platform_name'),
url=request.form.get('url'),
icon_class=request.form.get('icon_class'),
display_order=int(request.form.get('display_order', 0)),
animation_delay=request.form.get('animation_delay', '0s'),
is_active=request.form.get('is_active') == 'on'
)
db.session.add(link)
db.session.commit()
flash('Link social aggiunto con successo!', 'success')
return redirect(url_for('admin.social_links_manage'))
@route_admin.route('/social-links/<int:link_id>/edit', methods=['POST'])
@login_required
def social_link_edit(link_id):
"""Edit social link"""
link = SocialLink.query.get_or_404(link_id)
link.platform_name = request.form.get('platform_name', link.platform_name)
link.url = request.form.get('url', link.url)
link.icon_class = request.form.get('icon_class', link.icon_class)
link.display_order = int(request.form.get('display_order', link.display_order))
link.animation_delay = request.form.get('animation_delay', link.animation_delay)
link.is_active = request.form.get('is_active') == 'on'
db.session.commit()
flash('Link social aggiornato con successo!', 'success')
return redirect(url_for('admin.social_links_manage'))
@route_admin.route('/social-links/<int:link_id>/delete', methods=['POST'])
@login_required
def social_link_delete(link_id):
"""Delete social link"""
link = SocialLink.query.get_or_404(link_id)
db.session.delete(link)
db.session.commit()
flash('Link social eliminato con successo!', 'success')
return redirect(url_for('admin.social_links_manage'))

View File

@@ -6,9 +6,11 @@
"""
API Routes for managing portfolio data dynamically
Provides REST endpoints for CRUD operations on Profile, Skills, Projects, and Social Links
All write operations (POST, PUT, DELETE) require authentication
"""
from flask import Blueprint, jsonify, request
from flask_login import login_required
from models import db, Profile, Skill, Project, ProjectTag, SocialLink
route_api = Blueprint('api', __name__, url_prefix='/api')
@@ -28,6 +30,7 @@ def get_profile():
@route_api.route('/profile', methods=['PUT'])
@login_required
def update_profile():
"""Update profile information"""
profile = Profile.query.first()
@@ -58,6 +61,7 @@ def get_skills():
@route_api.route('/skills', methods=['POST'])
@login_required
def create_skill():
"""Create a new skill"""
data = request.json
@@ -75,6 +79,7 @@ def create_skill():
@route_api.route('/skills/<int:skill_id>', methods=['PUT'])
@login_required
def update_skill(skill_id):
"""Update a skill"""
skill = Skill.query.get_or_404(skill_id)
@@ -92,6 +97,7 @@ def update_skill(skill_id):
@route_api.route('/skills/<int:skill_id>', methods=['DELETE'])
@login_required
def delete_skill(skill_id):
"""Delete a skill"""
skill = Skill.query.get_or_404(skill_id)
@@ -112,6 +118,7 @@ def get_projects():
@route_api.route('/projects', methods=['POST'])
@login_required
def create_project():
"""Create a new project"""
data = request.json
@@ -144,6 +151,7 @@ def create_project():
@route_api.route('/projects/<int:project_id>', methods=['PUT'])
@login_required
def update_project(project_id):
"""Update a project"""
project = Project.query.get_or_404(project_id)
@@ -177,6 +185,7 @@ def update_project(project_id):
@route_api.route('/projects/<int:project_id>', methods=['DELETE'])
@login_required
def delete_project(project_id):
"""Delete a project"""
project = Project.query.get_or_404(project_id)
@@ -197,6 +206,7 @@ def get_social_links():
@route_api.route('/social-links', methods=['POST'])
@login_required
def create_social_link():
"""Create a new social link"""
data = request.json
@@ -214,6 +224,7 @@ def create_social_link():
@route_api.route('/social-links/<int:link_id>', methods=['PUT'])
@login_required
def update_social_link(link_id):
"""Update a social link"""
link = SocialLink.query.get_or_404(link_id)
@@ -231,6 +242,7 @@ def update_social_link(link_id):
@route_api.route('/social-links/<int:link_id>', methods=['DELETE'])
@login_required
def delete_social_link(link_id):
"""Delete a social link"""
link = SocialLink.query.get_or_404(link_id)

62
routes/auth.py Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright Hersel Giannella
"""
Authentication routes for login/logout
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, current_user
from models import User, db
from datetime import datetime
route_auth = Blueprint('auth', __name__, url_prefix='/auth')
@route_auth.route('/login', methods=['GET', 'POST'])
def login():
"""Login page"""
# Se l'utente è già autenticato, reindirizza alla dashboard
if current_user.is_authenticated:
return redirect(url_for('admin.dashboard'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember', False)
if not username or not password:
flash('Per favore inserisci username e password.', 'danger')
return render_template('auth/login.html')
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
if not user.is_active:
flash('Il tuo account è stato disabilitato.', 'danger')
return render_template('auth/login.html')
# Aggiorna last_login
user.last_login = datetime.utcnow()
db.session.commit()
login_user(user, remember=remember)
flash(f'Benvenuto {user.username}!', 'success')
# Redirect alla pagina richiesta o alla dashboard
next_page = request.args.get('next')
return redirect(next_page) if next_page else redirect(url_for('admin.dashboard'))
else:
flash('Username o password non corretti.', 'danger')
return render_template('auth/login.html')
@route_auth.route('/logout')
def logout():
"""Logout user"""
logout_user()
flash('Logout effettuato con successo.', 'info')
return redirect(url_for('route_home.home'))

140
templates/admin/base.html Normal file
View File

@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Dashboard{% endblock %} - Portfolio</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--sidebar-width: 250px;
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: var(--sidebar-width);
background: var(--primary-gradient);
color: white;
padding: 0;
overflow-y: auto;
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.sidebar-menu {
padding: 1rem 0;
}
.sidebar-menu a {
color: rgba(255,255,255,0.8);
text-decoration: none;
padding: 0.75rem 1.5rem;
display: block;
transition: all 0.3s;
}
.sidebar-menu a:hover,
.sidebar-menu a.active {
background: rgba(255,255,255,0.1);
color: white;
}
.main-content {
margin-left: var(--sidebar-width);
padding: 2rem;
}
.top-bar {
background: white;
padding: 1rem 2rem;
margin: -2rem -2rem 2rem -2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.stat-card {
background: white;
border-radius: 10px;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
transition: transform 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
}
.btn-gradient {
background: var(--primary-gradient);
border: none;
color: white;
}
.btn-gradient:hover {
opacity: 0.9;
color: white;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h4 class="mb-0"><i class="fas fa-briefcase me-2"></i>Portfolio Admin</h4>
<small class="text-white-50">Ciao, {{ current_user.username }}</small>
</div>
<nav class="sidebar-menu">
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint == 'admin.dashboard' %}active{% endif %}">
<i class="fas fa-th-large me-2"></i>Dashboard
</a>
<a href="{{ url_for('admin.profile_manage') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}">
<i class="fas fa-user me-2"></i>Profilo
</a>
<a href="{{ url_for('admin.skills_manage') }}" class="{% if 'skills' in request.endpoint %}active{% endif %}">
<i class="fas fa-code me-2"></i>Competenze
</a>
<a href="{{ url_for('admin.projects_manage') }}" class="{% if 'projects' in request.endpoint %}active{% endif %}">
<i class="fas fa-folder-open me-2"></i>Progetti
</a>
<a href="{{ url_for('admin.social_links_manage') }}" class="{% if 'social' in request.endpoint %}active{% endif %}">
<i class="fas fa-share-alt me-2"></i>Link Social
</a>
<hr class="border-white my-3 mx-3">
<a href="{{ url_for('route_home.home') }}" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>Visualizza Sito
</a>
<a href="{{ url_for('auth.logout') }}">
<i class="fas fa-sign-out-alt me-2"></i>Logout
</a>
</nav>
</div>
<!-- Main Content -->
<div class="main-content">
<div class="top-bar">
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0">{% block page_title %}Dashboard{% endblock %}</h3>
<div>
<span class="badge bg-success"><i class="fas fa-check-circle me-1"></i>Online</span>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,126 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block content %}
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Progetti</h6>
<h2 class="mb-0">{{ stats.projects }}</h2>
</div>
<div class="fs-1 text-primary">
<i class="fas fa-folder-open"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Competenze</h6>
<h2 class="mb-0">{{ stats.skills }}</h2>
</div>
<div class="fs-1 text-success">
<i class="fas fa-code"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Link Social</h6>
<h2 class="mb-0">{{ stats.social_links }}</h2>
</div>
<div class="fs-1 text-info">
<i class="fas fa-share-alt"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Pubblicati</h6>
<h2 class="mb-0">{{ stats.published_projects }}</h2>
</div>
<div class="fs-1 text-warning">
<i class="fas fa-eye"></i>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="fas fa-chart-bar me-2"></i>Panoramica</h5>
</div>
<div class="card-body">
<h6>Benvenuto nella Dashboard Admin</h6>
<p class="text-muted">
Da qui puoi gestire tutti i contenuti del tuo portfolio. Usa il menu a sinistra per navigare tra le diverse sezioni.
</p>
<div class="mt-4">
<h6>Azioni Rapide</h6>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ url_for('admin.projects_manage') }}" class="btn btn-gradient">
<i class="fas fa-plus me-2"></i>Nuovo Progetto
</a>
<a href="{{ url_for('admin.skills_manage') }}" class="btn btn-outline-primary">
<i class="fas fa-plus me-2"></i>Nuova Skill
</a>
<a href="{{ url_for('route_home.home') }}" class="btn btn-outline-secondary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>Visualizza Sito
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="fas fa-info-circle me-2"></i>Info Sistema</h5>
</div>
<div class="card-body">
<div class="mb-3">
<small class="text-muted">Utente</small>
<p class="mb-0"><strong>{{ current_user.username }}</strong></p>
</div>
<div class="mb-3">
<small class="text-muted">Email</small>
<p class="mb-0">{{ current_user.email }}</p>
</div>
<div class="mb-3">
<small class="text-muted">Ultimo Accesso</small>
<p class="mb-0">
{% if current_user.last_login %}
{{ current_user.last_login.strftime('%d/%m/%Y %H:%M') }}
{% else %}
Primo accesso
{% endif %}
</p>
</div>
<hr>
<div class="d-grid">
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-danger">
<i class="fas fa-sign-out-alt me-2"></i>Logout
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "admin/base.html" %}
{% block title %}Gestione Profilo{% endblock %}
{% block page_title %}Gestione Profilo{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<form method="POST" action="{{ url_for('admin.profile_edit') }}">
<div class="mb-3">
<label for="title" class="form-label">Titolo</label>
<input type="text" class="form-control" id="title" name="title"
value="{{ profile.title if profile else '' }}" required>
</div>
<div class="mb-3">
<label for="lead_text" class="form-label">Testo Principale</label>
<textarea class="form-control" id="lead_text" name="lead_text" rows="3" required>{{ profile.lead_text if profile else '' }}</textarea>
</div>
<div class="mb-3">
<label for="description_1" class="form-label">Descrizione 1</label>
<textarea class="form-control" id="description_1" name="description_1" rows="3">{{ profile.description_1 if profile else '' }}</textarea>
</div>
<div class="mb-3">
<label for="description_2" class="form-label">Descrizione 2</label>
<textarea class="form-control" id="description_2" name="description_2" rows="3">{{ profile.description_2 if profile else '' }}</textarea>
</div>
<div class="mb-3">
<label for="years_experience" class="form-label">Anni di Esperienza</label>
<input type="number" class="form-control" id="years_experience" name="years_experience"
value="{{ profile.years_experience if profile else 0 }}">
</div>
<div class="mb-3">
<label for="cv_url" class="form-label">URL CV (opzionale)</label>
<input type="url" class="form-control" id="cv_url" name="cv_url"
value="{{ profile.cv_url if profile else '' }}">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-gradient">
<i class="fas fa-save me-2"></i>Salva Modifiche
</button>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Annulla
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,83 @@
{% extends "admin/base.html" %}
{% block title %}{{ 'Modifica' if project else 'Nuovo' }} Progetto{% endblock %}
{% block page_title %}{{ 'Modifica' if project else 'Nuovo' }} Progetto{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="title" class="form-label">Titolo *</label>
<input type="text" class="form-control" id="title" name="title"
value="{{ project.title if project else '' }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Descrizione *</label>
<textarea class="form-control" id="description" name="description" rows="4" required>{{ project.description if project else '' }}</textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="image_url" class="form-label">URL Immagine</label>
<input type="text" class="form-control" id="image_url" name="image_url"
value="{{ project.image_url if project else '' }}" placeholder="img/project.webp">
<small class="text-muted">Percorso relativo alla cartella static/</small>
</div>
<div class="col-md-6 mb-3">
<label for="github_url" class="form-label">URL GitHub</label>
<input type="url" class="form-control" id="github_url" name="github_url"
value="{{ project.github_url if project else '' }}">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="demo_url" class="form-label">URL Demo</label>
<input type="url" class="form-control" id="demo_url" name="demo_url"
value="{{ project.demo_url if project else '' }}">
</div>
<div class="col-md-3 mb-3">
<label for="display_order" class="form-label">Ordine Visualizzazione</label>
<input type="number" class="form-control" id="display_order" name="display_order"
value="{{ project.display_order if project else 0 }}">
</div>
<div class="col-md-3 mb-3">
<label for="animation_delay" class="form-label">Delay Animazione</label>
<input type="text" class="form-control" id="animation_delay" name="animation_delay"
value="{{ project.animation_delay if project else '0s' }}" placeholder="0.2s">
</div>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (separati da virgola)</label>
<input type="text" class="form-control" id="tags" name="tags"
value="{% if project %}{% for tag in project.tags|sort(attribute='display_order') %}{{ tag.name }}:{{ tag.color_class }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}"
placeholder="Python:bg-primary, Flask:bg-info, Docker:bg-success">
<small class="text-muted">Formato: Nome:colore, Nome:colore (es: Python:bg-primary, Flask:bg-info)</small>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="is_published" name="is_published"
{% if not project or project.is_published %}checked{% endif %}>
<label class="form-check-label" for="is_published">
Pubblica il progetto
</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-gradient">
<i class="fas fa-save me-2"></i>Salva
</button>
<a href="{{ url_for('admin.projects_manage') }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Annulla
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "admin/base.html" %}
{% block title %}Gestione Progetti{% endblock %}
{% block page_title %}Gestione Progetti{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_for('admin.project_add') }}" class="btn btn-gradient">
<i class="fas fa-plus me-2"></i>Nuovo Progetto
</a>
</div>
<div class="row g-4">
{% for project in projects %}
<div class="col-md-4">
<div class="card h-100">
{% if project.image_url %}
<img src="{{ url_for('static', filename=project.image_url) }}" class="card-img-top" alt="{{ project.title }}">
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ project.title }}</h5>
<p class="card-text text-muted small">{{ project.description[:100] }}...</p>
<div class="mb-2">
{% for tag in project.tags %}
<span class="badge {{ tag.color_class }} me-1">{{ tag.name }}</span>
{% endfor %}
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{% if project.is_published %}
<i class="fas fa-eye text-success"></i> Pubblicato
{% else %}
<i class="fas fa-eye-slash text-danger"></i> Bozza
{% endif %}
</small>
<div>
<a href="{{ url_for('admin.project_edit', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit"></i>
</a>
<form method="POST" action="{{ url_for('admin.project_delete', project_id=project.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Sicuro di voler eliminare?')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "admin/base.html" %}
{% block title %}Gestione Competenze{% endblock %}
{% block page_title %}Gestione Competenze{% endblock %}
{% block content %}
<div class="card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">Aggiungi Nuova Competenza</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.skill_add') }}" class="row g-3">
<div class="col-md-3">
<input type="text" class="form-control" name="name" placeholder="Nome (es. Python)" required>
</div>
<div class="col-md-3">
<input type="text" class="form-control" name="icon_class" placeholder="Icona (es. fab fa-python)" required>
</div>
<div class="col-md-2">
<input type="text" class="form-control" name="category" placeholder="Categoria">
</div>
<div class="col-md-2">
<input type="number" class="form-control" name="display_order" placeholder="Ordine" value="0">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-gradient w-100">
<i class="fas fa-plus me-2"></i>Aggiungi
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">Lista Competenze</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Icona</th>
<th>Nome</th>
<th>Categoria</th>
<th>Ordine</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
{% for skill in skills %}
<tr>
<td><i class="{{ skill.icon_class }} fa-2x text-primary"></i></td>
<td>{{ skill.name }}</td>
<td><span class="badge bg-secondary">{{ skill.category or '-' }}</span></td>
<td>{{ skill.display_order }}</td>
<td>
{% if skill.is_active %}
<span class="badge bg-success">Attiva</span>
{% else %}
<span class="badge bg-danger">Disattiva</span>
{% endif %}
</td>
<td>
<form method="POST" action="{{ url_for('admin.skill_delete', skill_id=skill.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Sicuro di voler eliminare?')">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,76 @@
{% extends "admin/base.html" %}
{% block title %}Gestione Link Social{% endblock %}
{% block page_title %}Gestione Link Social{% endblock %}
{% block content %}
<div class="card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">Aggiungi Nuovo Link</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.social_link_add') }}" class="row g-3">
<div class="col-md-3">
<input type="text" class="form-control" name="platform_name" placeholder="Nome (es. LinkedIn)" required>
</div>
<div class="col-md-4">
<input type="url" class="form-control" name="url" placeholder="URL completo" required>
</div>
<div class="col-md-2">
<input type="text" class="form-control" name="icon_class" placeholder="Icona" required>
</div>
<div class="col-md-1">
<input type="number" class="form-control" name="display_order" placeholder="Ordine" value="0">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-gradient w-100">
<i class="fas fa-plus me-2"></i>Aggiungi
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Icona</th>
<th>Piattaforma</th>
<th>URL</th>
<th>Ordine</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
{% for link in social_links %}
<tr>
<td><i class="{{ link.icon_class }} fa-2x text-primary"></i></td>
<td>{{ link.platform_name }}</td>
<td><a href="{{ link.url }}" target="_blank" class="text-truncate d-inline-block" style="max-width: 200px;">{{ link.url }}</a></td>
<td>{{ link.display_order }}</td>
<td>
{% if link.is_active %}
<span class="badge bg-success">Attivo</span>
{% else %}
<span class="badge bg-danger">Disattivo</span>
{% endif %}
</td>
<td>
<form method="POST" action="{{ url_for('admin.social_link_delete', link_id=link.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Sicuro di voler eliminare?')">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

112
templates/auth/login.html Normal file
View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Portfolio Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
max-width: 450px;
width: 100%;
}
.login-card {
background: white;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
padding: 2.5rem;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header i {
font-size: 3rem;
color: #667eea;
margin-bottom: 1rem;
}
.btn-login {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
padding: 0.75rem;
font-weight: 600;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<i class="fas fa-lock"></i>
<h2>Admin Login</h2>
<p class="text-muted">Accedi per gestire il tuo portfolio</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label for="username" class="form-label">
<i class="fas fa-user me-2"></i>Username
</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">
<i class="fas fa-key me-2"></i>Password
</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">
Ricordami
</label>
</div>
<button type="submit" class="btn btn-primary btn-login w-100">
<i class="fas fa-sign-in-alt me-2"></i>Accedi
</button>
</form>
<div class="text-center mt-4">
<a href="{{ url_for('route_home.home') }}" class="text-decoration-none">
<i class="fas fa-arrow-left me-2"></i>Torna al Portfolio
</a>
</div>
</div>
<div class="text-center mt-3 text-white">
<small>
<i class="fas fa-info-circle me-1"></i>
Credenziali di default: admin / admin123
</small>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>