security: Restrict member-only features to MEMBER role

Modules now requiring MEMBER role or higher:
- NordaGPT (/chat) - with dedicated landing page for non-members
- Wiadomości (/wiadomosci) - private messaging
- Tablica B2B (/tablica) - business classifieds
- Kontakty (/kontakty) - member contact information

Non-members see a promotional page explaining the benefits
of NordaGPT membership instead of being simply redirected.

This provides clear value proposition for NORDA membership
while protecting member-exclusive features.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-01 21:33:27 +01:00
parent 579b4636bc
commit 6bf243d1cb
5 changed files with 215 additions and 2 deletions

View File

@ -14,10 +14,12 @@ from sqlalchemy import func, desc
from . import bp from . import bp
from database import ( from database import (
SessionLocal, AIChatConversation, AIChatMessage, AIChatFeedback, AIAPICostLog SessionLocal, AIChatConversation, AIChatMessage, AIChatFeedback, AIAPICostLog,
SystemRole
) )
from nordabiz_chat import NordaBizChatEngine from nordabiz_chat import NordaBizChatEngine
from extensions import csrf from extensions import csrf
from utils.decorators import member_required
# Logger # Logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,13 +32,17 @@ logger = logging.getLogger(__name__)
@bp.route('/chat') @bp.route('/chat')
@login_required @login_required
def chat(): def chat():
"""AI Chat interface""" """AI Chat interface - requires MEMBER role"""
# SECURITY: NordaGPT is only for members (MEMBER role or higher)
if not current_user.has_role(SystemRole.MEMBER):
return render_template('chat_members_only.html'), 403
return render_template('chat.html') return render_template('chat.html')
@bp.route('/api/chat/settings', methods=['GET', 'POST']) @bp.route('/api/chat/settings', methods=['GET', 'POST'])
@csrf.exempt @csrf.exempt
@login_required @login_required
@member_required
def chat_settings(): def chat_settings():
"""Get or update chat settings (model selection, monthly cost)""" """Get or update chat settings (model selection, monthly cost)"""
if request.method == 'GET': if request.method == 'GET':
@ -92,6 +98,7 @@ def chat_settings():
@bp.route('/api/chat/start', methods=['POST']) @bp.route('/api/chat/start', methods=['POST'])
@csrf.exempt @csrf.exempt
@login_required @login_required
@member_required
def chat_start(): def chat_start():
"""Start new chat conversation""" """Start new chat conversation"""
try: try:
@ -118,6 +125,7 @@ def chat_start():
@bp.route('/api/chat/<int:conversation_id>/message', methods=['POST']) @bp.route('/api/chat/<int:conversation_id>/message', methods=['POST'])
@csrf.exempt @csrf.exempt
@login_required @login_required
@member_required
def chat_send_message(conversation_id): def chat_send_message(conversation_id):
"""Send message to AI chat""" """Send message to AI chat"""
try: try:
@ -231,6 +239,7 @@ def chat_send_message(conversation_id):
@bp.route('/api/chat/<int:conversation_id>/history', methods=['GET']) @bp.route('/api/chat/<int:conversation_id>/history', methods=['GET'])
@login_required @login_required
@member_required
def chat_get_history(conversation_id): def chat_get_history(conversation_id):
"""Get conversation history""" """Get conversation history"""
try: try:
@ -263,6 +272,7 @@ def chat_get_history(conversation_id):
@bp.route('/api/chat/conversations', methods=['GET']) @bp.route('/api/chat/conversations', methods=['GET'])
@login_required @login_required
@member_required
def chat_list_conversations(): def chat_list_conversations():
"""Get list of user's conversations for sidebar""" """Get list of user's conversations for sidebar"""
db = SessionLocal() db = SessionLocal()
@ -293,6 +303,7 @@ def chat_list_conversations():
@bp.route('/api/chat/<int:conversation_id>/delete', methods=['DELETE']) @bp.route('/api/chat/<int:conversation_id>/delete', methods=['DELETE'])
@login_required @login_required
@member_required
def chat_delete_conversation(conversation_id): def chat_delete_conversation(conversation_id):
"""Delete a conversation""" """Delete a conversation"""
db = SessionLocal() db = SessionLocal()
@ -325,6 +336,7 @@ def chat_delete_conversation(conversation_id):
@bp.route('/api/chat/feedback', methods=['POST']) @bp.route('/api/chat/feedback', methods=['POST'])
@login_required @login_required
@member_required
def chat_feedback(): def chat_feedback():
"""API: Submit feedback for AI response""" """API: Submit feedback for AI response"""
try: try:

View File

@ -13,10 +13,12 @@ from . import bp
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, User from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, User
from sqlalchemy import desc from sqlalchemy import desc
from utils.helpers import sanitize_input from utils.helpers import sanitize_input
from utils.decorators import member_required
@bp.route('/', endpoint='classifieds_index') @bp.route('/', endpoint='classifieds_index')
@login_required @login_required
@member_required
def index(): def index():
"""Tablica ogłoszeń B2B""" """Tablica ogłoszeń B2B"""
listing_type = request.args.get('type', '') listing_type = request.args.get('type', '')
@ -65,6 +67,7 @@ def index():
@bp.route('/nowe', methods=['GET', 'POST'], endpoint='classifieds_new') @bp.route('/nowe', methods=['GET', 'POST'], endpoint='classifieds_new')
@login_required @login_required
@member_required
def new(): def new():
"""Dodaj nowe ogłoszenie""" """Dodaj nowe ogłoszenie"""
if request.method == 'POST': if request.method == 'POST':
@ -108,6 +111,7 @@ def new():
@bp.route('/<int:classified_id>', endpoint='classifieds_view') @bp.route('/<int:classified_id>', endpoint='classifieds_view')
@login_required @login_required
@member_required
def view(classified_id): def view(classified_id):
"""Szczegóły ogłoszenia""" """Szczegóły ogłoszenia"""
db = SessionLocal() db = SessionLocal()
@ -185,6 +189,7 @@ def view(classified_id):
@bp.route('/<int:classified_id>/zakoncz', methods=['POST'], endpoint='classifieds_close') @bp.route('/<int:classified_id>/zakoncz', methods=['POST'], endpoint='classifieds_close')
@login_required @login_required
@member_required
def close(classified_id): def close(classified_id):
"""Zamknij ogłoszenie""" """Zamknij ogłoszenie"""
db = SessionLocal() db = SessionLocal()
@ -207,6 +212,7 @@ def close(classified_id):
@bp.route('/<int:classified_id>/delete', methods=['POST'], endpoint='classifieds_delete') @bp.route('/<int:classified_id>/delete', methods=['POST'], endpoint='classifieds_delete')
@login_required @login_required
@member_required
def delete(classified_id): def delete(classified_id):
"""Usuń ogłoszenie (admin only)""" """Usuń ogłoszenie (admin only)"""
if not current_user.can_access_admin_panel(): if not current_user.can_access_admin_panel():
@ -231,6 +237,7 @@ def delete(classified_id):
@bp.route('/<int:classified_id>/toggle-active', methods=['POST'], endpoint='classifieds_toggle_active') @bp.route('/<int:classified_id>/toggle-active', methods=['POST'], endpoint='classifieds_toggle_active')
@login_required @login_required
@member_required
def toggle_active(classified_id): def toggle_active(classified_id):
"""Aktywuj/dezaktywuj ogłoszenie (admin only)""" """Aktywuj/dezaktywuj ogłoszenie (admin only)"""
if not current_user.can_access_admin_panel(): if not current_user.can_access_admin_panel():
@ -260,6 +267,7 @@ def toggle_active(classified_id):
@bp.route('/<int:classified_id>/interest', methods=['POST'], endpoint='classifieds_interest') @bp.route('/<int:classified_id>/interest', methods=['POST'], endpoint='classifieds_interest')
@login_required @login_required
@member_required
def toggle_interest(classified_id): def toggle_interest(classified_id):
"""Toggle zainteresowania ogłoszeniem""" """Toggle zainteresowania ogłoszeniem"""
db = SessionLocal() db = SessionLocal()
@ -312,6 +320,7 @@ def toggle_interest(classified_id):
@bp.route('/<int:classified_id>/interests', endpoint='classifieds_interests') @bp.route('/<int:classified_id>/interests', endpoint='classifieds_interests')
@login_required @login_required
@member_required
def list_interests(classified_id): def list_interests(classified_id):
"""Lista zainteresowanych (tylko dla autora ogłoszenia)""" """Lista zainteresowanych (tylko dla autora ogłoszenia)"""
db = SessionLocal() db = SessionLocal()
@ -357,6 +366,7 @@ def list_interests(classified_id):
@bp.route('/<int:classified_id>/ask', methods=['POST'], endpoint='classifieds_ask') @bp.route('/<int:classified_id>/ask', methods=['POST'], endpoint='classifieds_ask')
@login_required @login_required
@member_required
def ask_question(classified_id): def ask_question(classified_id):
"""Zadaj pytanie do ogłoszenia""" """Zadaj pytanie do ogłoszenia"""
db = SessionLocal() db = SessionLocal()
@ -405,6 +415,7 @@ def ask_question(classified_id):
@bp.route('/<int:classified_id>/question/<int:question_id>/answer', methods=['POST'], endpoint='classifieds_answer') @bp.route('/<int:classified_id>/question/<int:question_id>/answer', methods=['POST'], endpoint='classifieds_answer')
@login_required @login_required
@member_required
def answer_question(classified_id, question_id): def answer_question(classified_id, question_id):
"""Odpowiedz na pytanie (tylko autor ogłoszenia)""" """Odpowiedz na pytanie (tylko autor ogłoszenia)"""
db = SessionLocal() db = SessionLocal()
@ -457,6 +468,7 @@ def answer_question(classified_id, question_id):
@bp.route('/<int:classified_id>/question/<int:question_id>/hide', methods=['POST'], endpoint='classifieds_hide_question') @bp.route('/<int:classified_id>/question/<int:question_id>/hide', methods=['POST'], endpoint='classifieds_hide_question')
@login_required @login_required
@member_required
def hide_question(classified_id, question_id): def hide_question(classified_id, question_id):
"""Ukryj/pokaż pytanie (tylko autor ogłoszenia)""" """Ukryj/pokaż pytanie (tylko autor ogłoszenia)"""
db = SessionLocal() db = SessionLocal()
@ -495,6 +507,7 @@ def hide_question(classified_id, question_id):
@bp.route('/<int:classified_id>/questions', endpoint='classifieds_questions') @bp.route('/<int:classified_id>/questions', endpoint='classifieds_questions')
@login_required @login_required
@member_required
def list_questions(classified_id): def list_questions(classified_id):
"""Lista pytań i odpowiedzi do ogłoszenia""" """Lista pytań i odpowiedzi do ogłoszenia"""
db = SessionLocal() db = SessionLocal()

View File

@ -13,6 +13,8 @@ from flask import render_template, request, redirect, url_for, flash, current_ap
from flask_login import login_required, current_user from flask_login import login_required, current_user
from sqlalchemy import or_ from sqlalchemy import or_
from utils.decorators import member_required
from . import bp from . import bp
from database import SessionLocal, ExternalContact from database import SessionLocal, ExternalContact
@ -21,6 +23,7 @@ logger = logging.getLogger(__name__)
@bp.route('/', endpoint='contacts_list') @bp.route('/', endpoint='contacts_list')
@login_required @login_required
@member_required
def list(): def list():
""" """
Lista kontaktów zewnętrznych - urzędy, instytucje, partnerzy. Lista kontaktów zewnętrznych - urzędy, instytucje, partnerzy.
@ -95,6 +98,7 @@ def list():
@bp.route('/<int:contact_id>', endpoint='contact_detail') @bp.route('/<int:contact_id>', endpoint='contact_detail')
@login_required @login_required
@member_required
def detail(contact_id): def detail(contact_id):
""" """
Szczegóły kontaktu zewnętrznego - pełna karta osoby. Szczegóły kontaktu zewnętrznego - pełna karta osoby.
@ -133,6 +137,7 @@ def detail(contact_id):
@bp.route('/dodaj', methods=['GET', 'POST'], endpoint='contact_add') @bp.route('/dodaj', methods=['GET', 'POST'], endpoint='contact_add')
@login_required @login_required
@member_required
def add(): def add():
""" """
Dodawanie nowego kontaktu zewnętrznego. Dodawanie nowego kontaktu zewnętrznego.
@ -198,6 +203,7 @@ def add():
@bp.route('/<int:contact_id>/edytuj', methods=['GET', 'POST'], endpoint='contact_edit') @bp.route('/<int:contact_id>/edytuj', methods=['GET', 'POST'], endpoint='contact_edit')
@login_required @login_required
@member_required
def edit(contact_id): def edit(contact_id):
""" """
Edycja kontaktu zewnętrznego. Edycja kontaktu zewnętrznego.
@ -267,6 +273,7 @@ def edit(contact_id):
@bp.route('/<int:contact_id>/usun', methods=['POST'], endpoint='contact_delete') @bp.route('/<int:contact_id>/usun', methods=['POST'], endpoint='contact_delete')
@login_required @login_required
@member_required
def delete(contact_id): def delete(contact_id):
""" """
Usuwanie kontaktu zewnętrznego (soft delete). Usuwanie kontaktu zewnętrznego (soft delete).

View File

@ -13,6 +13,7 @@ from flask_login import login_required, current_user
from . import bp from . import bp
from database import SessionLocal, User, PrivateMessage, UserNotification, UserBlock, Classified from database import SessionLocal, User, PrivateMessage, UserNotification, UserBlock, Classified
from utils.helpers import sanitize_input from utils.helpers import sanitize_input
from utils.decorators import member_required
# ============================================================ # ============================================================
@ -21,6 +22,7 @@ from utils.helpers import sanitize_input
@bp.route('/wiadomosci') @bp.route('/wiadomosci')
@login_required @login_required
@member_required
def messages_inbox(): def messages_inbox():
"""Skrzynka odbiorcza""" """Skrzynka odbiorcza"""
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
@ -52,6 +54,7 @@ def messages_inbox():
@bp.route('/wiadomosci/wyslane') @bp.route('/wiadomosci/wyslane')
@login_required @login_required
@member_required
def messages_sent(): def messages_sent():
"""Wysłane wiadomości""" """Wysłane wiadomości"""
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
@ -77,6 +80,7 @@ def messages_sent():
@bp.route('/wiadomosci/nowa') @bp.route('/wiadomosci/nowa')
@login_required @login_required
@member_required
def messages_new(): def messages_new():
"""Formularz nowej wiadomości""" """Formularz nowej wiadomości"""
recipient_id = request.args.get('to', type=int) recipient_id = request.args.get('to', type=int)
@ -124,6 +128,7 @@ def messages_new():
@bp.route('/wiadomosci/wyslij', methods=['POST']) @bp.route('/wiadomosci/wyslij', methods=['POST'])
@login_required @login_required
@member_required
def messages_send(): def messages_send():
"""Wyślij wiadomość""" """Wyślij wiadomość"""
recipient_id = request.form.get('recipient_id', type=int) recipient_id = request.form.get('recipient_id', type=int)
@ -171,6 +176,7 @@ def messages_send():
@bp.route('/wiadomosci/<int:message_id>') @bp.route('/wiadomosci/<int:message_id>')
@login_required @login_required
@member_required
def messages_view(message_id): def messages_view(message_id):
"""Czytaj wiadomość""" """Czytaj wiadomość"""
db = SessionLocal() db = SessionLocal()
@ -214,6 +220,7 @@ def messages_view(message_id):
@bp.route('/wiadomosci/<int:message_id>/odpowiedz', methods=['POST']) @bp.route('/wiadomosci/<int:message_id>/odpowiedz', methods=['POST'])
@login_required @login_required
@member_required
def messages_reply(message_id): def messages_reply(message_id):
"""Odpowiedz na wiadomość""" """Odpowiedz na wiadomość"""
content = request.form.get('content', '').strip() content = request.form.get('content', '').strip()
@ -262,6 +269,7 @@ def messages_reply(message_id):
@bp.route('/api/messages/unread-count') @bp.route('/api/messages/unread-count')
@login_required @login_required
@member_required
def api_unread_count(): def api_unread_count():
"""API: Liczba nieprzeczytanych wiadomości""" """API: Liczba nieprzeczytanych wiadomości"""
db = SessionLocal() db = SessionLocal()
@ -281,6 +289,7 @@ def api_unread_count():
@bp.route('/api/notifications') @bp.route('/api/notifications')
@login_required @login_required
@member_required
def api_notifications(): def api_notifications():
"""API: Get user notifications""" """API: Get user notifications"""
limit = request.args.get('limit', 20, type=int) limit = request.args.get('limit', 20, type=int)
@ -330,6 +339,7 @@ def api_notifications():
@bp.route('/api/notifications/<int:notification_id>/read', methods=['POST']) @bp.route('/api/notifications/<int:notification_id>/read', methods=['POST'])
@login_required @login_required
@member_required
def api_notification_mark_read(notification_id): def api_notification_mark_read(notification_id):
"""API: Mark notification as read""" """API: Mark notification as read"""
db = SessionLocal() db = SessionLocal()
@ -355,6 +365,7 @@ def api_notification_mark_read(notification_id):
@bp.route('/api/notifications/read-all', methods=['POST']) @bp.route('/api/notifications/read-all', methods=['POST'])
@login_required @login_required
@member_required
def api_notifications_mark_all_read(): def api_notifications_mark_all_read():
"""API: Mark all notifications as read""" """API: Mark all notifications as read"""
db = SessionLocal() db = SessionLocal()
@ -379,6 +390,7 @@ def api_notifications_mark_all_read():
@bp.route('/api/notifications/unread-count') @bp.route('/api/notifications/unread-count')
@login_required @login_required
@member_required
def api_notifications_unread_count(): def api_notifications_unread_count():
"""API: Get unread notifications count""" """API: Get unread notifications count"""
db = SessionLocal() db = SessionLocal()

View File

@ -0,0 +1,169 @@
{% extends "base.html" %}
{% block title %}NordaGPT - Dla Członków Izby{% endblock %}
{% block extra_css %}
<style>
.members-only-container {
max-width: 800px;
margin: 60px auto;
padding: 40px;
text-align: center;
}
.members-only-icon {
font-size: 80px;
margin-bottom: 24px;
display: block;
}
.members-only-title {
font-size: 2rem;
font-weight: 700;
color: var(--norda-primary);
margin-bottom: 16px;
}
.members-only-subtitle {
font-size: 1.2rem;
color: var(--text-secondary);
margin-bottom: 32px;
line-height: 1.6;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 24px;
margin: 40px 0;
text-align: left;
}
.feature-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--border-color);
}
.feature-card-icon {
font-size: 32px;
margin-bottom: 12px;
}
.feature-card-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.feature-card-desc {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.5;
}
.cta-section {
background: linear-gradient(135deg, var(--norda-primary) 0%, #1a5a8a 100%);
border-radius: 16px;
padding: 32px;
margin-top: 40px;
color: white;
}
.cta-title {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 12px;
}
.cta-text {
opacity: 0.9;
margin-bottom: 20px;
}
.cta-button {
display: inline-block;
background: white;
color: var(--norda-primary);
padding: 12px 32px;
border-radius: 8px;
font-weight: 600;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
color: var(--norda-primary);
}
.back-link {
margin-top: 24px;
display: block;
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %}
<div class="members-only-container">
<span class="members-only-icon">🤖</span>
<h1 class="members-only-title">NordaGPT - Asystent AI dla Członków</h1>
<p class="members-only-subtitle">
NordaGPT to inteligentny asystent, który zna wszystkie firmy członkowskie Izby NORDA.<br>
Ta funkcja jest dostępna wyłącznie dla członków stowarzyszenia.
</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-card-icon">🔍</div>
<div class="feature-card-title">Wyszukiwanie firm</div>
<div class="feature-card-desc">
Znajdź partnerów biznesowych po usługach, kompetencjach lub lokalizacji
</div>
</div>
<div class="feature-card">
<div class="feature-card-icon">💬</div>
<div class="feature-card-title">Naturalna rozmowa</div>
<div class="feature-card-desc">
Zadawaj pytania w języku naturalnym - AI zrozumie Twoje potrzeby
</div>
</div>
<div class="feature-card">
<div class="feature-card-icon">📊</div>
<div class="feature-card-title">Pełna baza wiedzy</div>
<div class="feature-card-desc">
Dostęp do aktualności, wydarzeń, ogłoszeń B2B i dyskusji na forum
</div>
</div>
<div class="feature-card">
<div class="feature-card-icon">🤝</div>
<div class="feature-card-title">Rekomendacje</div>
<div class="feature-card-desc">
Poznaj opinie innych członków o firmach i ich usługach
</div>
</div>
</div>
<div class="cta-section">
<div class="cta-title">Chcesz korzystać z NordaGPT?</div>
<p class="cta-text">
Dołącz do Izby Przedsiębiorców NORDA i zyskaj dostęp do asystenta AI oraz wszystkich korzyści członkostwa.
</p>
<a href="https://norda-biznes.info/dolacz" class="cta-button" target="_blank">
Dowiedz się więcej o członkostwie
</a>
</div>
<a href="{{ url_for('public.index') }}" class="back-link">
← Wróć do katalogu firm
</a>
</div>
{% endblock %}